diff --git a/src/middleware/rateLimit.js b/src/middleware/rateLimit.js new file mode 100644 index 0000000..441e805 --- /dev/null +++ b/src/middleware/rateLimit.js @@ -0,0 +1,16 @@ +export function createRateLimiter({ limit, windowMs, now = Date.now }) { + const counters = new Map(); + let currentWindow = null; + + return function allow(key) { + const window = Math.floor(now() / windowMs); + if (window !== currentWindow) { + counters.clear(); + currentWindow = window; + } + const count = counters.get(key) ?? 0; + if (count >= limit) return false; + counters.set(key, count + 1); + return true; + }; +} diff --git a/src/routes/notes.js b/src/routes/notes.js index 8248dc1..1943b71 100644 --- a/src/routes/notes.js +++ b/src/routes/notes.js @@ -1,8 +1,14 @@ import { createNote, deleteNote, getNote, listNotes, updateNote } from '../services/notes.js'; import { noteSummary } from '../utils/format.js'; +import { pagination } from '../utils/validate.js'; export const notesRoutes = { - 'GET /notes': (req) => ({ status: 200, body: listNotes(req.user.id).map(noteSummary) }), + 'GET /notes': (req) => { + const { page, pageSize } = pagination(req.query); + const notes = listNotes(req.user.id); + const items = notes.slice((page - 1) * pageSize, page * pageSize).map(noteSummary); + return { status: 200, body: { items, page, pageSize, total: notes.length } }; + }, 'GET /notes/:id': (req) => { const note = getNote(req.user.id, req.params.id); return note ? { status: 200, body: note } : { status: 404, body: { error: 'not found' } }; diff --git a/src/utils/format.js b/src/utils/format.js index 24a3a09..4c90f41 100644 --- a/src/utils/format.js +++ b/src/utils/format.js @@ -3,7 +3,7 @@ // Dates are shown as YYYY-MM-DD in UTC. export function formatDate(ms) { const d = new Date(ms); - const month = String(d.getUTCMonth()).padStart(2, '0'); + const month = String(d.getUTCMonth() + 1).padStart(2, '0'); const day = String(d.getUTCDate()).padStart(2, '0'); return `${d.getUTCFullYear()}-${month}-${day}`; } diff --git a/src/utils/validate.js b/src/utils/validate.js index 72f348f..afc7e99 100644 --- a/src/utils/validate.js +++ b/src/utils/validate.js @@ -19,6 +19,17 @@ export function optionalTags(obj) { return [...new Set(tags.map((t) => String(t).toLowerCase().trim()).filter(Boolean))]; } +export function pagination(query, { defaultPageSize = 5, maxPageSize = 50 } = {}) { + const int = (field, fallback) => { + const raw = query?.[field]; + if (raw === undefined || raw === '') return fallback; + const n = Number(raw); + if (!Number.isInteger(n) || n < 1) throw new ValidationError(field, 'must be a positive integer'); + return n; + }; + return { page: int('page', 1), pageSize: Math.min(int('pageSize', defaultPageSize), maxPageSize) }; +} + export function requireEmail(obj, field = 'email') { const value = requireString(obj, field); if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) throw new ValidationError(field, 'must be an email address'); diff --git a/tests/api.test.js b/tests/api.test.js index 32d499e..5f18686 100644 --- a/tests/api.test.js +++ b/tests/api.test.js @@ -15,11 +15,35 @@ test('notes need a valid token', () => { assert.equal(handle({ method: 'GET', path: '/notes', headers: {} }).status, 401); }); -test('lists only my notes, newest first', () => { +test('lists only my notes, newest first, paginated', () => { const res = as('u1', { method: 'GET', path: '/notes' }); assert.equal(res.status, 200); - assert.equal(res.body.length, 8); - assert.equal(res.body[0].title, 'Car service'); + assert.equal(res.body.total, 8); + assert.equal(res.body.page, 1); + assert.equal(res.body.pageSize, 5); + assert.equal(res.body.items.length, 5); + assert.equal(res.body.items[0].title, 'Car service'); +}); + +test('pagination returns later pages and the remainder', () => { + const res = as('u1', { method: 'GET', path: '/notes', query: { page: '2' } }); + assert.equal(res.body.items.length, 3); + const empty = as('u1', { method: 'GET', path: '/notes', query: { page: '3' } }); + assert.deepEqual(empty.body.items, []); + assert.equal(empty.body.total, 8); +}); + +test('pageSize is honored and capped at 50', () => { + const small = as('u1', { method: 'GET', path: '/notes', query: { pageSize: '2' } }); + assert.equal(small.body.items.length, 2); + const big = as('u1', { method: 'GET', path: '/notes', query: { pageSize: '500' } }); + assert.equal(big.body.pageSize, 50); + assert.equal(big.body.items.length, 8); +}); + +test('bad pagination params are 400s', () => { + assert.equal(as('u1', { method: 'GET', path: '/notes', query: { page: '0' } }).status, 400); + assert.equal(as('u1', { method: 'GET', path: '/notes', query: { pageSize: 'abc' } }).status, 400); }); test('creates and deletes a note', () => { diff --git a/tests/format.test.js b/tests/format.test.js new file mode 100644 index 0000000..9951740 --- /dev/null +++ b/tests/format.test.js @@ -0,0 +1,14 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatDate, noteSummary } from '../src/utils/format.js'; + +test('formatDate uses 1-based months', () => { + assert.equal(formatDate(Date.UTC(2026, 2, 15)), '2026-03-15'); + assert.equal(formatDate(Date.UTC(2026, 0, 1)), '2026-01-01'); + assert.equal(formatDate(Date.UTC(2026, 11, 31)), '2026-12-31'); +}); + +test('noteSummary shows the creation month correctly', () => { + const note = { id: 'n1', title: 't', body: 'b', tags: [], createdAt: Date.UTC(2026, 2, 15) }; + assert.equal(noteSummary(note).created, '2026-03-15'); +}); diff --git a/tests/rateLimit.test.js b/tests/rateLimit.test.js new file mode 100644 index 0000000..51d1998 --- /dev/null +++ b/tests/rateLimit.test.js @@ -0,0 +1,30 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRateLimiter } from '../src/middleware/rateLimit.js'; + +test('allows up to limit calls per key, then blocks', () => { + const allow = createRateLimiter({ limit: 2, windowMs: 1000, now: () => 0 }); + assert.equal(allow('a'), true); + assert.equal(allow('a'), true); + assert.equal(allow('a'), false); +}); + +test('keys are tracked independently', () => { + const allow = createRateLimiter({ limit: 1, windowMs: 1000, now: () => 0 }); + assert.equal(allow('a'), true); + assert.equal(allow('b'), true); + assert.equal(allow('a'), false); +}); + +test('windows are fixed and aligned to multiples of windowMs', () => { + let t = 900; + const allow = createRateLimiter({ limit: 1, windowMs: 1000, now: () => t }); + assert.equal(allow('a'), true); + t = 999; + assert.equal(allow('a'), false); + t = 1000; + assert.equal(allow('a'), true); + assert.equal(allow('a'), false); + t = 2000; + assert.equal(allow('a'), true); +});