Project all 1 written
One small authenticated notes app, end to end: built, served, requested and tested by panels that really ran.
31 One project end to end
This is the lesson where the pieces become one app. Shelf is a small notes app: people sign up, log in, and keep a list of short notes about the books they are reading. It is deliberately small enough to read in an hour, and it uses what the earlier lessons taught, with nothing new: file-based routes and layouts (Part 2), server components and Server Actions (Part 3, Part 5), route handlers and the proxy, metadata, a local image and font (Part 6), sessions by hand (Part 7) and tests (lesson 28). It is a separate project from the one the other lessons used, built and started once for this lesson. The React course ends with its own project; this one is the server-side counterpart.
The project, as built
The panel builds the project and prints three things: the files of the project (a script lists
the directory the build ran in), the build log with its route table, and the checks. The files shown
above the output are the core of it: the data layer, the session module, the two server-side entry
points (the actions and the route handler), the proxy and the configuration. The remaining files
(the pages) are small and you can read them in tools/projects/proj/.
package.json
{
"name": "shelf",
"private": true,
"type": "module",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"next": "16.3.1",
"react": "19.2.7",
"react-dom": "19.2.7"
}
}
dot-env
# A demo value, so that this project runs the same everywhere. A real deployment sets its own
# long random secret in the host's environment and never commits it.
SESSION_SECRET=shelf-demo-secret-replace-me-in-real-life
proxy.js
import { NextResponse } from 'next/server';
// An optimistic check only: no cookie, no notes page. Whether the cookie is any good is decided
// by app/_lib/session.js, in every page and action that needs a user.
export function proxy(request) {
if (!request.cookies.has('shelf_session')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/notes/:path*'],
};
app/_lib/store.js
// The data layer, with no Next.js and no hidden state: a store is made from an adapter, so the
// same code runs against a file in the app and against memory in a test.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
const empty = () => ({ users: [], notes: [], next: 1 });
/** An adapter is { read() -> data | null, write(data) }. */
export function fileAdapter(path) {
return {
read() {
try {
return JSON.parse(readFileSync(path, 'utf8'));
} catch {
return null;
}
},
write(data) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(data, null, 2));
},
};
}
export function memoryAdapter() {
let saved = null;
return {
read: () => (saved === null ? null : structuredClone(saved)),
write: (data) => { saved = structuredClone(data); },
};
}
export function createStore(adapter) {
const load = () => adapter.read() ?? empty();
return {
addUser({ name, email, passwordHash }) {
const data = load();
if (data.users.some((user) => user.email === email)) return null;
const user = { id: data.next++, name, email, passwordHash };
data.users.push(user);
adapter.write(data);
return user;
},
findUser: (email) => load().users.find((user) => user.email === email) ?? null,
findUserById: (id) => load().users.find((user) => user.id === id) ?? null,
addNote(userId, title) {
const data = load();
const note = { id: data.next++, userId, title };
data.notes.push(note);
adapter.write(data);
return note;
},
notesFor: (userId) => load().notes.filter((note) => note.userId === userId),
};
}
app/_lib/db.js
// The one store the app uses: a JSON file under data/ in the project directory.
import { join } from 'node:path';
import { createStore, fileAdapter } from './store.js';
export const db = createStore(fileAdapter(join(process.cwd(), 'data', 'shelf.json')));
app/_lib/session.js
import 'server-only';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { readSession, signSession } from './session-core.js';
import { db } from './db.js';
export const COOKIE = 'shelf_session';
const SECRET = process.env.SESSION_SECRET;
if (!SECRET) throw new Error('SESSION_SECRET is not set');
export async function createSession(userId) {
const token = signSession({ sub: userId, exp: Date.now() + 60 * 60 * 1000 }, SECRET);
(await cookies()).set(COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
maxAge: 60 * 60,
});
}
export async function getSession() {
const payload = readSession((await cookies()).get(COOKIE)?.value, SECRET);
return (payload && db.findUserById(payload.sub)) || null;
}
export async function verifySession() {
const user = await getSession();
if (!user) redirect('/login');
return user;
}
export async function destroySession() {
(await cookies()).delete(COOKIE);
}
app/auth/actions.js
'use server';
import { redirect } from 'next/navigation';
import { createSession, destroySession } from '../_lib/session.js';
import { hashPassword, verifyPassword } from '../_lib/session-core.js';
import { db } from '../_lib/db.js';
import { validateSignup } from '../_lib/validate.js';
const back = (path, message) => redirect(`${path}?error=${encodeURIComponent(message)}`);
export async function signup(formData) {
const values = {
name: String(formData.get('name') ?? ''),
email: String(formData.get('email') ?? '').trim().toLowerCase(),
password: String(formData.get('password') ?? ''),
};
const problems = Object.values(validateSignup(values));
if (problems.length > 0) back('/signup', problems[0]);
const user = db.addUser({ name: values.name.trim(), email: values.email, passwordHash: hashPassword(values.password) });
if (!user) back('/signup', 'That email is already registered.');
await createSession(user.id);
redirect('/notes');
}
export async function login(formData) {
const user = db.findUser(String(formData.get('email') ?? '').trim().toLowerCase());
if (!user || !verifyPassword(String(formData.get('password') ?? ''), user.passwordHash)) back('/login', 'Wrong email or password.');
await createSession(user.id);
redirect('/notes');
}
export async function logout() {
await destroySession();
redirect('/login');
}
app/notes/actions.js
'use server';
import { redirect } from 'next/navigation';
import { verifySession } from '../_lib/session.js';
import { db } from '../_lib/db.js';
import { validateNote } from '../_lib/validate.js';
export async function addNote(formData) {
const user = await verifySession();
const title = String(formData.get('title') ?? '');
const problem = validateNote(title);
if (problem) redirect(`/notes?error=${encodeURIComponent(problem)}`);
db.addNote(user.id, title.trim());
redirect('/notes');
}
app/api/notes/route.js
import { getSession } from '../../_lib/session.js';
import { db } from '../../_lib/db.js';
export async function GET() {
const user = await getSession();
if (!user) return Response.json({ error: 'not signed in' }, { status: 401 });
return Response.json({ notes: db.notesFor(user.id).map((note) => note.title) });
}
shelf-check.mjs
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
const root = process.env.APP_DIR;
const lines = [];
function walk(dir, depth) {
const entries = readdirSync(dir, { withFileTypes: true })
.filter((entry) => !(depth === 0 && (entry.name === 'node_modules' || entry.name === '.next')))
.sort((a, b) => (a.name < b.name ? -1 : 1));
for (const entry of entries) {
lines.push(' '.repeat(depth) + entry.name + (entry.isDirectory() ? '/' : ''));
if (entry.isDirectory()) walk(join(dir, entry.name), depth + 1);
}
}
walk(root, 0);
console.log('the project (without node_modules and .next):');
console.log(lines.join('\n'));
const log = readFileSync(join(dirname(root), 'build.out'), 'utf8');
const symbol = (path) => (new RegExp('^[┌├└│]\\s+([○●ƒ◐])\\s+' + path.replace(/[/]/g, '[/]') + '(?:\\s|$)', 'm').exec(log) || [])[1];
const secret = /^SESSION_SECRET=(.+)$/m.exec(readFileSync(root + '/.env', 'utf8'))[1];
const chunks = readdirSync(root + '/.next/static', { recursive: true, withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith('.js'))
.map((entry) => readFileSync(join(entry.parentPath, entry.name), 'utf8'));
check('the home page is static and every page or handler that reads the session is dynamic', ['/', '/signup', '/login', '/notes', '/api/notes'].map(symbol), ['○', 'ƒ', 'ƒ', 'ƒ', 'ƒ']);
check('the proxy was built', existsSync(root + '/.next/server/middleware-manifest.json'), true);
check('there are client scripts to search for the secret', chunks.length > 0, true);
check('and the session secret is in none of them', chunks.some((chunk) => chunk.includes(secret)), false);
check('the build did not create the data folder: the store writes it on first use', existsSync(root + '/data'), false);
What next build printed (Next 16.3.1), then the checks, when the build ran it
next build
▲ Next.js 16.3.1 (Turbopack)
- Environments: .env
✓ Running next.config.mjs took <t>
Creating an optimized production build ...
✓ Compiled successfully in <t>
Running TypeScript ...
Finished TypeScript in <t> ...
Collecting page data using <n> workers ...
✓ Generating static pages using <n> workers (7/7) in <t>
Finalizing page optimization ...
Route (app)
┌ ○ /
├ ○ /_not-found
├ ƒ /api/notes
├ ƒ /login
├ ƒ /notes
└ ƒ /signup
ƒ Proxy (Middleware)
○ (Static) prerendered as static content
ƒ (Dynamic) server-rendered on demand
ok the log shows "Route (app)"
ok the log shows "○ /_not-found"
ok the log shows "ƒ /notes"
ok the log shows "ƒ /api/notes"
ok the log shows "ƒ Proxy (Middleware)"
ok the log shows "○ (Static)"
ok the log shows "ƒ (Dynamic)"
the project (without node_modules and .next):
.env
app/
_lib/
db.js
session-core.js
session.js
store.js
validate.js
api/
notes/
route.js
auth/
actions.js
fonts/
Inter-400.woff2
globals.css
layout.js
login/
page.js
notes/
actions.js
page.js
page.js
signup/
page.js
next.config.mjs
package.json
proxy.js
public/
shelf.png
ok the home page is static and every page or handler that reads the session is dynamic
ok the proxy was built
ok there are client scripts to search for the secret
ok and the session secret is in none of them
ok the build did not create the data folder: the store writes it on first use
The check script ran after the build, with APP_DIR set to the project, and read the files in .next.
How it fits together
The store (store.js) is the one idea in this project that the earlier
lessons did not use. It is a set of functions over an adapter, a pair of
read() and write(data). The app's adapter keeps one JSON file
(db.js joins the working directory and data/shelf.json); a test's
adapter keeps the same data in memory. The code is the same in both, which is what lets the unit
tests below run in milliseconds with nothing on disk. A real app would put a database behind the
same functions. The session module is lesson 26's, with the user table replaced by
the store, and with SESSION_SECRET required: the module throws when it is loaded if it is not
set, because a missing secret should be a loud failure and not a quiet fallback. The secret comes from
the project's .env (stored here as dot-env, as in lesson 22). The panel
checked that its value is in no script sent to browsers. The actions are glue: they
read the form, call the pure functions, write through the store and redirect. The proxy
sends visitors without a cookie away from /notes; the pages and actions do the real check
(lesson 27). The route handler answers 401 or the notes as JSON.
Using it
Now the running app. The panel loads the home page and reads its metadata, its picture and its font; asks the optimiser for the picture; signs up through the form with no JavaScript; and opens the file the store wrote. That file is where an app like this keeps its data, so it is worth seeing once.
app/layout.js
import Link from 'next/link';
import localFont from 'next/font/local';
import './globals.css';
const inter = localFont({ src: './fonts/Inter-400.woff2', weight: '400', style: 'normal', display: 'swap' });
export const metadata = {
title: { template: '%s | Shelf', default: 'Shelf' },
description: 'Notes on the books you are reading.',
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={inter.className}>
<nav>
<Link href="/">Home</Link>
<Link href="/notes">Notes</Link>
</nav>
{children}
</body>
</html>
);
}
app/page.js
import Image from 'next/image';
import Link from 'next/link';
export default function Home() {
return (
<main>
<h1>Shelf</h1>
<Image src="/shelf.png" alt="Five books on a shelf" width={120} height={80} priority />
<p>Keep notes on the books you are reading.</p>
<p>
<Link href="/signup">Sign up</Link> or <Link href="/login">log in</Link>.
</p>
</main>
);
}
walk.mjs
import { readFileSync } from 'node:fs';
const base = process.env.BASE_URL;
const ids = (html) => [...html.matchAll(/name="(\$ACTION_ID_[0-9a-f]+)"/g)].map((m) => m[1]);
const attr = (tag, name) => {
const m = new RegExp(' ' + name + '="([^"]*)"').exec(tag);
return m ? m[1].replace(/&/g, '&') : null;
};
const home = await (await fetch(base + '/')).text();
const signupPage = await (await fetch(base + '/signup')).text();
const img = /<img[^>]*>/.exec(home)[0];
const font = /<link rel="preload" href="([^"]*)" as="font"/.exec(home);
const webp = await fetch(base + attr(img, 'src'), { headers: { accept: 'image/webp' } });
console.log('title of / :', /<title>([^<]*)<\/title>/.exec(home)[1]);
console.log('title of /signup :', /<title>([^<]*)<\/title>/.exec(signupPage)[1]);
console.log('picture :', attr(img, 'width') + ' x ' + attr(img, 'height'), 'through the optimiser:', attr(img, 'src').startsWith('/_next/image?url=%2Fshelf.png'));
console.log('optimiser says :', webp.status, webp.headers.get('content-type'));
console.log('font preloaded :', font[1].startsWith('/_next/static/media/Inter_400'));
const body = new FormData();
body.append(ids(signupPage)[0], '');
for (const [name, value] of Object.entries({ name: 'Ada', email: 'ada-shelf@example.com', password: 'correct horse' })) body.append(name, value);
const res = await fetch(base + '/signup', { method: 'POST', body, redirect: 'manual', headers: { origin: base } });
const raw = readFileSync(process.env.APP_DIR + '/data/shelf.json', 'utf8');
const saved = JSON.parse(raw);
const user = saved.users.find((u) => u.email === 'ada-shelf@example.com');
console.log('sign-up :', res.status, new URL(res.headers.get('location'), base).pathname);
console.log('data/shelf.json : keys', Object.keys(saved).join(', '), '| user keys', Object.keys(user).join(', '));
console.log('stored hash :', user.passwordHash.replace(/[0-9a-f]/g, 'x'));
check('the home page uses the layout\u2019s default title', /<title>([^<]*)<\/title>/.exec(home)[1], 'Shelf');
check('a page in a child segment gets the title template', /<title>([^<]*)<\/title>/.exec(signupPage)[1], 'Sign up | Shelf');
check('the picture is sized and goes through the optimiser', [attr(img, 'width'), attr(img, 'height'), attr(img, 'src').startsWith('/_next/image?url=%2Fshelf.png')], ['120', '80', true]);
check('which answers with WebP', [webp.status, webp.headers.get('content-type')], [200, 'image/webp']);
check('the layout preloads the local font file', font[1].startsWith('/_next/static/media/Inter_400') && font[1].endsWith('.woff2'), true);
check('signing up redirects to the notes page', [res.status, new URL(res.headers.get('location'), base).pathname], [303, '/notes']);
check('the store wrote its file on first use, with users, notes and a counter', Object.keys(saved).sort(), ['next', 'notes', 'users']);
check('the user is saved with an id, a name, an email and a hash', Object.keys(user).sort(), ['email', 'id', 'name', 'passwordHash']);
check('the hash has the salt:scrypt form of lesson 26, and the password is nowhere in the file', [/^[0-9a-f]{32}:[0-9a-f]{64}$/.test(user.passwordHash), raw.includes('correct horse')], [true, false]);
What the driver printed (Next 16.3.1, next build + next start), when the build ran it
node walk.mjs
title of / : Shelf title of /signup : Sign up | Shelf picture : 120 x 80 through the optimiser: true optimiser says : 200 image/webp font preloaded : true sign-up : 303 /notes data/shelf.json : keys users, notes, next | user keys id, name, email, passwordHash stored hash : xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ok the home page uses the layout’s default title ok a page in a child segment gets the title template ok the picture is sized and goes through the optimiser ok which answers with WebP ok the layout preloads the local font file ok signing up redirects to the notes page ok the store wrote its file on first use, with users, notes and a counter ok the user is saved with an id, a name, an email and a hash ok the hash has the salt:scrypt form of lesson 26, and the password is nowhere in the file
The driver ran against next start -H 127.0.0.1 -p 0 of project proj, right after its next build; BASE_URL is the address Next printed.
The tests
Two files of node:test tests, as in lesson 28. The first needs no server: it tests
the store through its memory adapter, and the validation, password and token functions. The second
talks to the running app over HTTP and walks through what a person would do: be sent to the login
page, sign up, add notes, be refused when the cookie is forged, and sign out. A shared state matters
here: the store is a file, so every test signs up its own email, and the tests run in the order they
are written.
unit-tests.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore, memoryAdapter } from '../app/app/_lib/store.js';
import { hashPassword, readSession, signSession, verifyPassword } from '../app/app/_lib/session-core.js';
import { validateNote, validateSignup } from '../app/app/_lib/validate.js';
test('store: a new user gets an id and is found by email', () => {
const store = createStore(memoryAdapter());
const user = store.addUser({ name: 'Ada', email: 'ada@example.com', passwordHash: 'x' });
assert.equal(store.findUser('ada@example.com').id, user.id);
assert.equal(store.findUser('nobody@example.com'), null);
});
test('store: the same email cannot register twice', () => {
const store = createStore(memoryAdapter());
store.addUser({ name: 'Ada', email: 'ada@example.com', passwordHash: 'x' });
assert.equal(store.addUser({ name: 'Ada again', email: 'ada@example.com', passwordHash: 'y' }), null);
});
test('store: notes belong to the user who wrote them', () => {
const store = createStore(memoryAdapter());
const ada = store.addUser({ name: 'Ada', email: 'ada@example.com', passwordHash: 'x' });
const grace = store.addUser({ name: 'Grace', email: 'grace@example.com', passwordHash: 'x' });
store.addNote(ada.id, 'Middlemarch, chapter 3');
assert.deepEqual(store.notesFor(ada.id).map((note) => note.title), ['Middlemarch, chapter 3']);
assert.deepEqual(store.notesFor(grace.id), []);
});
test('store: a second store over the same adapter sees what the first saved', () => {
const adapter = memoryAdapter();
createStore(adapter).addUser({ name: 'Ada', email: 'ada@example.com', passwordHash: 'x' });
assert.equal(createStore(adapter).findUser('ada@example.com').name, 'Ada');
});
test('validateNote: empty and over-long notes are refused, with the reason', () => {
assert.equal(validateNote(' '), 'Write something.');
assert.equal(validateNote('x'.repeat(81)), 'Keep it under 80 characters.');
assert.equal(validateNote('Dracula, chapter 2'), '');
});
test('validateSignup: a short password and a bad email are both reported', () => {
assert.deepEqual(Object.keys(validateSignup({ name: 'Ada', email: 'nope', password: 'short' })).sort(), ['email', 'password']);
});
test('passwords: verified against the hash, not stored as text', () => {
const stored = hashPassword('correct horse');
assert.equal(stored.includes('correct horse'), false);
assert.equal(verifyPassword('correct horse', stored), true);
assert.equal(verifyPassword('battery staple', stored), false);
});
test('tokens: a signed session reads back with the same secret', () => {
assert.equal(readSession(signSession({ sub: 4, exp: 9_000 }, 's3cret'), 's3cret', 1_000).sub, 4);
});
test('tokens: one signed with another secret is refused', () => {
assert.equal(readSession(signSession({ sub: 4, exp: 9_000 }, 'other'), 's3cret', 1_000), null);
});
What the tests printed (Next 16.3.1, node:test + next start), when the build ran them
node unit-tests.mjs
ok store: a new user gets an id and is found by email ok store: the same email cannot register twice ok store: notes belong to the user who wrote them ok store: a second store over the same adapter sees what the first saved ok validateNote: empty and over-long notes are refused, with the reason ok validateSignup: a short password and a bad email are both reported ok passwords: verified against the hash, not stored as text ok tokens: a signed session reads back with the same secret ok tokens: one signed with another secret is refused
The tests ran under node against next start -H 127.0.0.1 -p 0 of project proj, right after its next build; BASE_URL is the address Next printed. The reporter is the course's own, tools/test-reporter.mjs: one line per test, with no durations. Run node --test yourself to see Node's usual report.
http-tests.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
const base = process.env.BASE_URL;
const ids = (html) => [...html.matchAll(/name="(\$ACTION_ID_[0-9a-f]+)"/g)].map((m) => m[1]);
const get = (path, cookie) => fetch(base + path, { redirect: 'manual', headers: cookie ? { cookie } : {} });
const pathOf = (res) => new URL(res.headers.get('location'), base).pathname;
const cookieOf = (res) => (res.headers.getSetCookie()[0] || '').split('; ')[0];
// Submit the form of a page the way a browser with JavaScript off would.
async function submit(path, fields, { cookie, form = 0 } = {}) {
const body = new FormData();
body.append(ids(await (await get(path, cookie)).text())[form], '');
for (const [name, value] of Object.entries(fields)) body.append(name, value);
return fetch(base + path, { method: 'POST', body, redirect: 'manual', headers: { origin: base, ...(cookie ? { cookie } : {}) } });
}
let cookie = '';
test('notes without a cookie redirect to the login page', async () => {
const res = await get('/notes');
assert.deepEqual([res.status, pathOf(res)], [307, '/login']);
});
test('signing up sets the session cookie and goes to the notes page', async () => {
const res = await submit('/signup', { name: 'Tess', email: 'tess@example.com', password: 'correct horse' });
cookie = cookieOf(res);
assert.deepEqual([res.status, pathOf(res), cookie.startsWith('shelf_session=')], [303, '/notes', true]);
});
test('signing up again with the same email is refused', async () => {
const res = await submit('/signup', { name: 'Tess', email: 'tess@example.com', password: 'correct horse' });
assert.equal(res.status, 303);
assert.match(res.headers.get('location'), /^\/signup\?error=That%20email%20is%20already%20registered\./);
});
test('the notes page says who is signed in', async () => {
const html = await (await get('/notes', cookie)).text();
assert.match(html, /Notes for tess@example\.com/);
});
test('a note is added and listed', async () => {
const res = await submit('/notes', { title: 'Persuasion, chapter 1' }, { cookie });
assert.deepEqual([res.status, pathOf(res)], [303, '/notes']);
const html = await (await get('/notes', cookie)).text();
assert.deepEqual([...html.matchAll(/<li>([^<]*)<\/li>/g)].map((m) => m[1]), ['Persuasion, chapter 1']);
});
test('an empty note is refused with a message', async () => {
const res = await submit('/notes', { title: ' ' }, { cookie });
assert.match(res.headers.get('location'), /^\/notes\?error=Write%20something\./);
});
test('the route handler lists the notes for the session and refuses without one', async () => {
const signedIn = await get('/api/notes', cookie);
assert.deepEqual([signedIn.status, await signedIn.json()], [200, { notes: ['Persuasion, chapter 1'] }]);
const anonymous = await get('/api/notes');
assert.deepEqual([anonymous.status, await anonymous.json()], [401, { error: 'not signed in' }]);
});
test('a cookie whose payload was edited to another user is refused', async () => {
const [payload, signature] = cookie.slice(cookie.indexOf('=') + 1).split('.');
const edited = { ...JSON.parse(Buffer.from(payload, 'base64url').toString()), sub: 1 };
const forged = 'shelf_session=' + Buffer.from(JSON.stringify(edited)).toString('base64url') + '.' + signature;
const page = await get('/notes', forged);
assert.deepEqual([page.status, pathOf(page)], [307, '/login']);
assert.equal((await get('/api/notes', forged)).status, 401);
});
test('a wrong password goes back to the login form with a message', async () => {
const res = await submit('/login', { email: 'tess@example.com', password: 'wrong horse' });
assert.deepEqual([res.status, res.headers.get('location'), res.headers.getSetCookie().length], [303, '/login?error=Wrong%20email%20or%20password.', 0]);
});
test('the right password signs in again', async () => {
const res = await submit('/login', { email: 'tess@example.com', password: 'correct horse' });
assert.deepEqual([res.status, pathOf(res), cookieOf(res).startsWith('shelf_session=')], [303, '/notes', true]);
});
test('signing out sends an expired cookie and goes to the login page', async () => {
const res = await submit('/notes', {}, { cookie, form: 1 });
assert.deepEqual([res.status, pathOf(res)], [303, '/login']);
assert.ok(res.headers.getSetCookie()[0].startsWith('shelf_session=;'));
assert.ok(res.headers.getSetCookie()[0].includes('1970'));
});
What the tests printed (Next 16.3.1, node:test + next start), when the build ran them
node http-tests.mjs
ok notes without a cookie redirect to the login page ok signing up sets the session cookie and goes to the notes page ok signing up again with the same email is refused ok the notes page says who is signed in ok a note is added and listed ok an empty note is refused with a message ok the route handler lists the notes for the session and refuses without one ok a cookie whose payload was edited to another user is refused ok a wrong password goes back to the login form with a message ok the right password signs in again ok signing out sends an expired cookie and goes to the login page
The tests ran under node against next start -H 127.0.0.1 -p 0 of project proj, right after its next build; BASE_URL is the address Next printed. The reporter is the course's own, tools/test-reporter.mjs: one line per test, with no durations. Run node --test yourself to see Node's usual report.
These tests are the safety net for changes: change the store to talk to a database, rewrite the session module, move a page, and run them again. What they do not cover is anything that needs a browser, such as the forms with JavaScript on, the pending states, and how the page looks. Those need the end-to-end tools lesson 28 listed.
What this app is not
Shelf is a lesson, not a service. Its data is one JSON file that two servers could corrupt, its sessions cannot be revoked, there is no password reset and no limit on login attempts, and the forms were only exercised with JavaScript off. All of that is outside what this course can run, and none of it is hidden: the code you saw is the whole app. What it does show is the shape that stays when the pieces are replaced: a thin layer of Next.js (routes, actions, the proxy) around plain functions that you can test, over a store you can swap.