From 32177b33f58e50c12a10f9ef8f0b264df5fd0642 Mon Sep 17 00:00:00 2001 From: matt Date: Thu, 2 Jul 2026 21:32:33 -0400 Subject: [PATCH] Build yap-blog platform --- .env.example | 20 + .gitignore | 14 +- README.md | 144 +- docker-compose.yml | 23 + docker/initdb/01-create-databases.sql | 5 + drizzle.config.ts | 15 + drizzle/0000_init.sql | 89 + drizzle/0001_theme.sql | 2 + drizzle/0002_themes-and-fonts.sql | 6 + drizzle/0003_more-themes-fonts.sql | 16 + drizzle/meta/0000_snapshot.json | 671 + drizzle/meta/0001_snapshot.json | 687 + drizzle/meta/0002_snapshot.json | 710 + drizzle/meta/0003_snapshot.json | 726 + drizzle/meta/_journal.json | 34 + next.config.ts | 4 +- package-lock.json | 11714 ++++++++++++++++ package.json | 42 +- playwright.config.ts | 30 + public/file.svg | 1 - public/globe.svg | 1 - public/next.svg | 1 - public/vercel.svg | 1 - public/window.svg | 1 - scripts/seed.ts | 763 + src/actions/auth.ts | 71 + src/actions/pages.ts | 80 + src/actions/posts.ts | 102 + src/actions/settings.ts | 104 + src/app/(public)/(home)/loading.tsx | 21 + src/app/(public)/(home)/page.tsx | 40 + src/app/(public)/[...rest]/page.tsx | 9 + src/app/(public)/layout.tsx | 31 + src/app/(public)/not-found.tsx | 20 + src/app/(public)/pages/[slug]/page.tsx | 21 + src/app/(public)/posts/(list)/loading.tsx | 21 + src/app/(public)/posts/(list)/page.tsx | 29 + src/app/(public)/posts/[slug]/page.tsx | 22 + src/app/(public)/tags/[slug]/page.tsx | 45 + src/app/admin/(panel)/layout.tsx | 63 + src/app/admin/(panel)/loading.tsx | 7 + src/app/admin/(panel)/not-found.tsx | 19 + src/app/admin/(panel)/page.tsx | 84 + .../admin/(panel)/pages/[id]/edit/page.tsx | 56 + .../admin/(panel)/pages/[id]/preview/page.tsx | 43 + src/app/admin/(panel)/pages/new/page.tsx | 16 + src/app/admin/(panel)/pages/page.tsx | 104 + .../admin/(panel)/posts/[id]/edit/page.tsx | 62 + .../admin/(panel)/posts/[id]/preview/page.tsx | 43 + src/app/admin/(panel)/posts/new/page.tsx | 19 + src/app/admin/(panel)/posts/page.tsx | 108 + src/app/admin/(panel)/settings/page.tsx | 32 + src/app/admin/login/page.tsx | 28 + src/app/api/admin/uploads/route.ts | 39 + src/app/error.tsx | 35 + src/app/global-error.tsx | 52 + src/app/globals.css | 587 +- src/app/layout.tsx | 165 +- src/app/not-found.tsx | 24 + src/app/page.tsx | 65 - src/app/uploads/[name]/route.ts | 37 + src/components/admin/ConfirmButton.tsx | 33 + src/components/admin/Flash.tsx | 22 + src/components/admin/FormTabs.tsx | 85 + src/components/admin/LoginForm.tsx | 44 + src/components/admin/PageForm.tsx | 150 + src/components/admin/PostForm.tsx | 310 + src/components/admin/RichTextEditor.tsx | 375 + src/components/admin/SettingsForm.tsx | 447 + src/components/admin/StatusBadge.tsx | 16 + src/components/admin/SubmitButton.tsx | 30 + src/components/public/ContentBody.tsx | 13 + src/components/public/EmptyState.tsx | 7 + src/components/public/FeaturedImage.tsx | 44 + src/components/public/MobileMenu.tsx | 128 + src/components/public/NavLinkItem.tsx | 22 + src/components/public/PageArticle.tsx | 16 + src/components/public/PaginationNav.tsx | 45 + src/components/public/PostArticle.tsx | 41 + src/components/public/PostCard.tsx | 53 + src/components/public/PostListSection.tsx | 41 + src/components/public/SiteFooter.tsx | 9 + src/components/public/SiteHeader.tsx | 49 + src/components/public/SiteSidebar.tsx | 44 + src/components/public/TagChips.tsx | 20 + src/components/ui.tsx | 105 + src/db/index.ts | 28 + src/db/schema.ts | 161 + src/lib/auth/cookies.ts | 22 + src/lib/auth/dal.ts | 21 + src/lib/auth/password.ts | 47 + src/lib/auth/session.ts | 52 + src/lib/excerpt.ts | 33 + src/lib/format.ts | 15 + src/lib/forms.ts | 28 + src/lib/html.ts | 21 + src/lib/markdown.ts | 29 + src/lib/pagination.ts | 19 + src/lib/params.ts | 6 + src/lib/sanitize-schema.ts | 20 + src/lib/services/errors.ts | 20 + src/lib/services/home.ts | 26 + src/lib/services/pages.ts | 106 + src/lib/services/posts.ts | 281 + src/lib/services/settings.ts | 89 + src/lib/services/tags.ts | 44 + src/lib/slug.ts | 36 + src/lib/themes.ts | 157 + src/lib/upload-client.ts | 21 + src/lib/uploads.ts | 67 + src/lib/validation.ts | 130 + tests/e2e/blog.spec.ts | 249 + tests/e2e/setup-db.ts | 39 + tests/global-setup.ts | 27 + tests/helpers/db.ts | 9 + tests/integration/auth.test.ts | 81 + tests/integration/home.test.ts | 147 + tests/integration/posts.test.ts | 99 + tests/integration/slugs.test.ts | 83 + tests/integration/tags.test.ts | 82 + tests/setup-env.ts | 12 + tests/unit/excerpt.test.ts | 52 + tests/unit/html.test.ts | 56 + tests/unit/markdown.test.ts | 41 + tests/unit/pagination.test.ts | 55 + tests/unit/slug.test.ts | 60 + tests/unit/uploads.test.ts | 65 + tsconfig.typecheck.json | 11 + vitest.config.ts | 18 + 129 files changed, 22722 insertions(+), 116 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.yml create mode 100644 docker/initdb/01-create-databases.sql create mode 100644 drizzle.config.ts create mode 100644 drizzle/0000_init.sql create mode 100644 drizzle/0001_theme.sql create mode 100644 drizzle/0002_themes-and-fonts.sql create mode 100644 drizzle/0003_more-themes-fonts.sql create mode 100644 drizzle/meta/0000_snapshot.json create mode 100644 drizzle/meta/0001_snapshot.json create mode 100644 drizzle/meta/0002_snapshot.json create mode 100644 drizzle/meta/0003_snapshot.json create mode 100644 drizzle/meta/_journal.json create mode 100644 package-lock.json create mode 100644 playwright.config.ts delete mode 100644 public/file.svg delete mode 100644 public/globe.svg delete mode 100644 public/next.svg delete mode 100644 public/vercel.svg delete mode 100644 public/window.svg create mode 100644 scripts/seed.ts create mode 100644 src/actions/auth.ts create mode 100644 src/actions/pages.ts create mode 100644 src/actions/posts.ts create mode 100644 src/actions/settings.ts create mode 100644 src/app/(public)/(home)/loading.tsx create mode 100644 src/app/(public)/(home)/page.tsx create mode 100644 src/app/(public)/[...rest]/page.tsx create mode 100644 src/app/(public)/layout.tsx create mode 100644 src/app/(public)/not-found.tsx create mode 100644 src/app/(public)/pages/[slug]/page.tsx create mode 100644 src/app/(public)/posts/(list)/loading.tsx create mode 100644 src/app/(public)/posts/(list)/page.tsx create mode 100644 src/app/(public)/posts/[slug]/page.tsx create mode 100644 src/app/(public)/tags/[slug]/page.tsx create mode 100644 src/app/admin/(panel)/layout.tsx create mode 100644 src/app/admin/(panel)/loading.tsx create mode 100644 src/app/admin/(panel)/not-found.tsx create mode 100644 src/app/admin/(panel)/page.tsx create mode 100644 src/app/admin/(panel)/pages/[id]/edit/page.tsx create mode 100644 src/app/admin/(panel)/pages/[id]/preview/page.tsx create mode 100644 src/app/admin/(panel)/pages/new/page.tsx create mode 100644 src/app/admin/(panel)/pages/page.tsx create mode 100644 src/app/admin/(panel)/posts/[id]/edit/page.tsx create mode 100644 src/app/admin/(panel)/posts/[id]/preview/page.tsx create mode 100644 src/app/admin/(panel)/posts/new/page.tsx create mode 100644 src/app/admin/(panel)/posts/page.tsx create mode 100644 src/app/admin/(panel)/settings/page.tsx create mode 100644 src/app/admin/login/page.tsx create mode 100644 src/app/api/admin/uploads/route.ts create mode 100644 src/app/error.tsx create mode 100644 src/app/global-error.tsx create mode 100644 src/app/not-found.tsx delete mode 100644 src/app/page.tsx create mode 100644 src/app/uploads/[name]/route.ts create mode 100644 src/components/admin/ConfirmButton.tsx create mode 100644 src/components/admin/Flash.tsx create mode 100644 src/components/admin/FormTabs.tsx create mode 100644 src/components/admin/LoginForm.tsx create mode 100644 src/components/admin/PageForm.tsx create mode 100644 src/components/admin/PostForm.tsx create mode 100644 src/components/admin/RichTextEditor.tsx create mode 100644 src/components/admin/SettingsForm.tsx create mode 100644 src/components/admin/StatusBadge.tsx create mode 100644 src/components/admin/SubmitButton.tsx create mode 100644 src/components/public/ContentBody.tsx create mode 100644 src/components/public/EmptyState.tsx create mode 100644 src/components/public/FeaturedImage.tsx create mode 100644 src/components/public/MobileMenu.tsx create mode 100644 src/components/public/NavLinkItem.tsx create mode 100644 src/components/public/PageArticle.tsx create mode 100644 src/components/public/PaginationNav.tsx create mode 100644 src/components/public/PostArticle.tsx create mode 100644 src/components/public/PostCard.tsx create mode 100644 src/components/public/PostListSection.tsx create mode 100644 src/components/public/SiteFooter.tsx create mode 100644 src/components/public/SiteHeader.tsx create mode 100644 src/components/public/SiteSidebar.tsx create mode 100644 src/components/public/TagChips.tsx create mode 100644 src/components/ui.tsx create mode 100644 src/db/index.ts create mode 100644 src/db/schema.ts create mode 100644 src/lib/auth/cookies.ts create mode 100644 src/lib/auth/dal.ts create mode 100644 src/lib/auth/password.ts create mode 100644 src/lib/auth/session.ts create mode 100644 src/lib/excerpt.ts create mode 100644 src/lib/format.ts create mode 100644 src/lib/forms.ts create mode 100644 src/lib/html.ts create mode 100644 src/lib/markdown.ts create mode 100644 src/lib/pagination.ts create mode 100644 src/lib/params.ts create mode 100644 src/lib/sanitize-schema.ts create mode 100644 src/lib/services/errors.ts create mode 100644 src/lib/services/home.ts create mode 100644 src/lib/services/pages.ts create mode 100644 src/lib/services/posts.ts create mode 100644 src/lib/services/settings.ts create mode 100644 src/lib/services/tags.ts create mode 100644 src/lib/slug.ts create mode 100644 src/lib/themes.ts create mode 100644 src/lib/upload-client.ts create mode 100644 src/lib/uploads.ts create mode 100644 src/lib/validation.ts create mode 100644 tests/e2e/blog.spec.ts create mode 100644 tests/e2e/setup-db.ts create mode 100644 tests/global-setup.ts create mode 100644 tests/helpers/db.ts create mode 100644 tests/integration/auth.test.ts create mode 100644 tests/integration/home.test.ts create mode 100644 tests/integration/posts.test.ts create mode 100644 tests/integration/slugs.test.ts create mode 100644 tests/integration/tags.test.ts create mode 100644 tests/setup-env.ts create mode 100644 tests/unit/excerpt.test.ts create mode 100644 tests/unit/html.test.ts create mode 100644 tests/unit/markdown.test.ts create mode 100644 tests/unit/pagination.test.ts create mode 100644 tests/unit/slug.test.ts create mode 100644 tests/unit/uploads.test.ts create mode 100644 tsconfig.typecheck.json create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fb71081 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# --- Database ------------------------------------------------------------- +# Connection string used by the app, drizzle-kit, and the seed script. +# Matches the Postgres service in docker-compose.yml. +DATABASE_URL=postgresql://blog:blog@localhost:5434/blog + +# Host port that docker-compose publishes Postgres on (container port 5432). +POSTGRES_PORT=5434 + +# --- Initial administrator -------------------------------------------------- +# Read by `npm run db:seed`; only a scrypt hash of the password is stored. +# Re-run the seed after changing these to update the stored credentials. +ADMIN_USERNAME=admin +ADMIN_PASSWORD=change-me-please + +# --- Test databases (optional overrides) ------------------------------------ +# Both databases are created automatically by docker/initdb on the first +# `docker compose up`. Unit/integration tests use TEST_DATABASE_URL and the +# Playwright suite uses E2E_DATABASE_URL; both are wiped on every run. +TEST_DATABASE_URL=postgresql://blog:blog@localhost:5434/blog_test +E2E_DATABASE_URL=postgresql://blog:blog@localhost:5434/blog_e2e diff --git a/.gitignore b/.gitignore index 5ef6a52..0ebfa94 100644 --- a/.gitignore +++ b/.gitignore @@ -30,8 +30,20 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# env files (the template stays tracked) .env* +!.env.example +/env + +# local agent/tool state +/.claude/ + +# playwright +/test-results/ +/playwright-report/ + +# runtime image uploads (see src/lib/uploads.ts) +/uploads/ # vercel .vercel diff --git a/README.md b/README.md index e215bc4..f682fbc 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,140 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +# Yap Blog — a small self-hosted blogging platform -## Getting Started +A single-admin blogging platform built with **Next.js (App Router) + React + TypeScript**, backed by **PostgreSQL** via **Drizzle ORM**. Ships with fifteen switchable themes and twelve selectable body fonts, all configured from the admin area. Posts and static pages are written in Markdown, organized with tags, and managed through a session-authenticated admin area. -First, run the development server: +![Stack](https://img.shields.io/badge/Next.js-16-blue) ![DB](https://img.shields.io/badge/PostgreSQL-17-blue) ![ORM](https://img.shields.io/badge/Drizzle-0.45-blue) + +## Quick start + +Requirements: **Node.js ≥ 20**, **Docker** with the compose plugin, and free local ports **5434** (Postgres) and **3000** (dev server) — both configurable. ```bash +# 1. Configuration (set ADMIN_PASSWORD to taste) +cp .env.example .env + +# 2. Database +docker compose up -d + +# 3. Dependencies +npm install + +# 4. Schema + demo content (admin credentials come from .env) +npm run db:migrate +npm run db:seed + +# 5. Go npm run dev -# or -yarn dev -# or -pnpm dev -# or -bun dev ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. +Open for the public site and for the admin area, signing in with the `ADMIN_USERNAME` / `ADMIN_PASSWORD` from your `.env`. -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. +> The seed is idempotent: the admin password hash is refreshed on every run (re-run it after changing `ADMIN_PASSWORD`), site settings are created only if missing, and demo content is only inserted into an empty database. It creates ~51 published posts (7 handwritten showcases plus generated topic filler so listings paginate to ~11 pages), 2 drafts, 9 tags, and 3 static pages. -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. +## Commands -## Learn More +| Command | Purpose | +| --- | --- | +| `npm run dev` | Development server on :3000 | +| `npm run build` | Production build | +| `npm start` | Serve the production build | +| `npm run lint` | ESLint | +| `npm run typecheck` | TypeScript compiler, no emit | +| `npm test` | Unit + integration tests (Vitest, uses the `blog_test` DB) | +| `npm run test:watch` | Vitest in watch mode | +| `npm run test:e2e` | Production build, then Playwright E2E (uses the `blog_e2e` DB) | +| `npm run test:all` | Both suites | +| `npm run db:generate` | Generate a new migration from schema changes | +| `npm run db:migrate` | Apply migrations | +| `npm run db:seed` | Seed admin, settings, and demo content | +| `npm run db:studio` | Drizzle Studio DB browser | -To learn more about Next.js, take a look at the following resources: +The `blog_test` and `blog_e2e` databases are created automatically the first time the Postgres volume initializes (`docker/initdb/`). Both are **wiped and re-migrated on every test run** — never point them at data you care about. If you created the volume with an older setup, recreate it with `docker compose down -v && docker compose up -d`. -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. +## Routes -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! +| Route | Purpose | +| --- | --- | +| `/` | Configured home page: all posts, one tag's posts, or a static page | +| `/posts` | All published posts, newest first, paginated (`?page=N`) | +| `/posts/[slug]` | One published post | +| `/tags/[slug]` | Published posts with that tag, paginated | +| `/pages/[slug]` | One published static page | +| `/admin` | Dashboard (auth required) | +| `/admin/login` | Sign in | +| `/admin/posts`, `/admin/posts/new`, `/admin/posts/[id]/edit`, `/admin/posts/[id]/preview` | Post management | +| `/admin/pages`, … | Static-page management (same shape as posts) | +| `/admin/settings` | Site title, header/footer text, theme, font, navigation, home-page mode, pagination & excerpt limits | -## Deploy on Vercel +## Architecture -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +``` +src/ +├── app/ # Routes only — thin, no business logic +│ ├── (public)/ # Public site, wrapped in header/sidebar/footer chrome +│ │ ├── (home)/ # / with its own loading skeleton +│ │ └── posts/(list)/ # /posts with its own loading skeleton +│ ├── admin/login/ # Login (outside the guarded group) +│ └── admin/(panel)/ # Guarded admin area (layout + every page re-check auth) +├── actions/ # Server actions: auth, posts, pages, settings, preview +├── components/ # public/ and admin/ UI + shared primitives (ui.tsx) +├── db/ # Drizzle schema + connection pool +├── lib/ +│ ├── auth/ # scrypt hashing, session store, cookie, DAL guards +│ ├── services/ # All database access (posts, tags, pages, settings, home) +│ └── … # slug, markdown, excerpt, pagination, validation, forms +├── drizzle/ # Generated SQL migrations (committed) +├── scripts/seed.ts # Idempotent seeding (also reused by the E2E setup) +└── tests/ # unit/ · integration/ · e2e/ +``` -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +### Key decisions + +- **Server components + server actions, no API layer.** Public pages are React Server Components that call the service layer directly; admin mutations are server actions. There are no JSON route handlers because nothing consumes them — one less surface to validate and keep in sync. Route handlers can be added later for an RSS feed or a public API without touching the services. +- **A service layer owns all SQL.** Files under `src/lib/services/` are the only place queries live. Pages and actions stay thin, and the integration tests exercise the exact code paths production uses. +- **Everything renders dynamically** (`force-dynamic` in the root layout). All content is admin-editable at runtime, so pages read the DB per request — plenty fast for an MVP and never stale. The obvious next optimization is tag-based caching (`revalidateTag`) around settings/posts. +- **Auth: opaque session tokens, scrypt passwords.** Login verifies against a scrypt hash (Node's built-in crypto; parameters encoded per-hash so they can be raised later). Sessions are 32-byte random tokens in an `httpOnly` `SameSite=Lax` cookie; the database stores only the SHA-256 of the token, so a leaked DB dump cannot forge cookies. Expired sessions are treated as absent and purged on login. A failed login costs one scrypt derivation whether or not the username exists, avoiding a user-enumeration timing signal. +- **Protection is server-side at three layers**: the admin layout redirects, every admin page calls `requireAdmin()` (React-`cache()`d per request), and every mutating server action calls it again — an action invoked directly over HTTP without a valid session cookie redirects instead of mutating. There is deliberately no middleware-only check to rely on. +- **Rich text editing, HTML storage.** Posts and pages are written in a WordPress-style WYSIWYG editor (Tiptap/ProseMirror): headings, bold/italic/underline/strike, inline code, links, lists, blockquotes, code blocks, tables with row/column controls, horizontal rules, undo/redo — plus inline images uploaded straight from the editor (toolbar button, drag-drop, or paste). Pasting plain-text **Markdown converts automatically** through the same remark pipeline used for seeding; pasting rich HTML uses ProseMirror's native handling. Bodies are stored as HTML and pass through one shared `rehype-sanitize` allowlist (GitHub schema + ``/table scaffolding) **both on save and on render** — scripts, event handlers, and `javascript:` URLs cannot survive either path, and the editor is WYSIWYG against the same `.markdown-body` styles the public site uses, so what you see is literally what publishes. +- **Slug policy.** Slugs are generated from titles (NFKD-normalized, diacritics stripped, hyphenated). A *generated* slug that collides is auto-suffixed (`-2`, `-3`, …); an *explicitly chosen* slug that collides is rejected with a field error — the author picked it, silently renaming it would surprise them. Uniqueness is also enforced by DB constraints, so a race between two requests ends in a caught constraint error, not a duplicate. +- **Deletion safety is in the schema.** `settings.home_tag_id` / `home_page_id` are `ON DELETE SET NULL` and nav items are `ON DELETE CASCADE` from their page, so deleting a tag or page featured on the home page or navigation degrades gracefully (home falls back to the post list; the nav item disappears). Unpublishing a page hides its nav items until it is republished. A `CHECK` constraint guarantees a nav item points at exactly one of URL/page. +- **HTTP 404 vs. streaming.** Slug routes (`/posts/[slug]`, `/tags/[slug]`, `/pages/[slug]`) have **no** loading boundary so unknown or draft content returns a real HTTP 404. The two listing routes have loading skeletons, which means an out-of-range `?page=` there streams a not-found UI with HTTP 200 plus a `noindex` meta tag (a "soft 404") — the deliberate trade-off for skeletons on the routes users actually wait on. Malformed pagination (`?page=abc`, `-1`, `1e9`) clamps to page 1. +- **Theming.** `globals.css` maps raw palette values to semantic tokens (`--background`, `--ink`, `--link`, …) and exposes those to Tailwind via `@theme inline`; components only ever use semantic utilities (`bg-surface`, `text-ink-strong`). The admin **Settings → Appearance** section switches the whole site (public + admin) between fifteen themes by stamping `data-theme` on ``; each theme is nothing but a token override block. Dark: **Solarized Dark** (default), **Dracula**, **Nord**, **Gruvbox Dark**, **Catppuccin Mocha**, **Tokyo Night**, **One Dark**, **Rosé Pine**, **Everforest Dark**, **Monokai**, **White on Black**. Light: **Solarized Light**, **Catppuccin Latte**, **GitHub Light**, **Black & White**. The two mono themes are deliberately pure grayscale. Selection color, blockquote borders, badges, and the browser `theme-color` all derive from tokens, so new themes need no component work. Adding a theme = one CSS block, one enum value in `src/db/schema.ts` (+ generated migration), and one entry in the `src/lib/themes.ts` registry — the `Record` type makes a missing entry a compile error. +- **Fonts.** The admin also picks a site-wide body font: sans — **Geist** (default), **Inter**, **Open Sans**, **Work Sans**, **Space Grotesk**, **Atkinson Hyperlegible**; serif — **Lora**, **Merriweather**, **Source Serif 4**, **EB Garamond**, **Playfair Display**; mono — **JetBrains Mono**. All are self-hosted via `next/font` (downloaded once at build time, no runtime Google requests). Only Geist is preloaded; the rest are declared `@font-face` rules the browser fetches solely when `data-font` on `` makes one active. Tailwind's `font-sans` resolves through `--font-body`, which each `[data-font="…"]` block remaps. Code blocks always stay in Geist Mono. The settings form previews each option in its actual typeface. +- **Accessibility.** Semantic landmarks, labelled navs, a skip-to-content link, visible `:focus-visible` rings, `aria-invalid`/`aria-describedby` wiring on form errors, an `Escape`-closable mobile menu, and alt-text support (with an explicit "decorative" convention) on featured images. Destructive admin actions confirm before submitting. + +### Images + +**Inline images** are uploaded from the editor to `POST /api/admin/uploads` (auth-required, 8 MB cap, MIME allowlist: PNG/JPEG/WebP/GIF/AVIF — SVG deliberately excluded). Files land in `./uploads/` (gitignored) under random UUID names — client filenames never touch the filesystem — and are served by the `GET /uploads/[name]` route handler with immutable cache headers (Next only serves `public/` files that existed at build time, hence the route). The filename pattern is validated on read, ruling out path traversal. Alt text is editable per image via the toolbar's **Alt** button when an image is selected. + +**Featured images** accept either a pasted URL or the same upload flow via the Upload button next to the field. Rendering uses a plain `` with an error fallback ("image unavailable") instead of `next/image`, because arbitrary admin-supplied URLs would require a wildcard `remotePatterns`, which turns the image optimizer into an open proxy. + +Moving to object storage later: point `saveUploadedImage` (src/lib/uploads.ts) at S3/R2/MinIO instead of the local directory and return the bucket URL — the endpoint, editor, and schema stay unchanged. Restricting `next/image` to that bucket's hostname would then restore image optimization. + +### Error handling + +Public and admin groups have scoped `not-found.tsx`; `error.tsx` shows a generic retry card (details stay in server logs, correlated by digest); `global-error.tsx` catches root-layout failures (e.g. DB down) with a self-contained page. Server actions return typed field/form errors — constraint violations and unexpected exceptions surface as friendly messages, never stack traces or connection strings. `generateMetadata` failures fall back to defaults rather than crashing the page. + +## Testing + +- **Unit** (`tests/unit/`): slugify + unique-slug suffixing, excerpt generation from stored HTML, both sanitizer pipelines (script stripping, event handlers, `javascript:` URLs, allowed editor marks), upload validation (MIME allowlist, size caps, filename generation), pagination parsing, URL validation. +- **Integration** (`tests/integration/`, real Postgres): draft exclusion from public queries, reverse-chronological ordering and pagination, publish/unpublish `publishedAt` semantics, duplicate-slug handling on create/update, tag visibility and filtering, home-page mode fallbacks after deletion/unpublication, nav resolution, password hashing, session lifecycle. +- **E2E** (`tests/e2e/`, Playwright against a production build): admin routes redirect anonymously; bad credentials rejected; a full editorial flow — login → compose in the rich editor (heading + bold via toolbar) → publish → public listing/post/tag pages → draft 404s → logout locks the admin again; an editor-capabilities flow — markdown paste conversion, `", + ); + document + .querySelector(".tiptap")! + .dispatchEvent( + new ClipboardEvent("paste", { clipboardData: dt, bubbles: true, cancelable: true }), + ); + }); + await expect(editor.getByRole("heading", { level: 2 })).toContainText("Pasted heading"); + await expect(editor.locator("strong")).toContainText("pasted bold"); + expect(await editor.innerHTML()).not.toContain("alert(1)"); + + // Upload an image through the toolbar's file input; it lands inline. + await page + .getByTestId("editor-image-input") + .setInputFiles({ name: "pixel.png", mimeType: "image/png", buffer: PNG_FIXTURE }); + await expect(editor.locator('img[src^="/uploads/"]')).toBeVisible(); + const uploadedSrc = await editor.locator('img[src^="/uploads/"]').getAttribute("src"); + + // Publish and verify the public page renders everything. + await page.getByLabel("Status").selectOption("published"); + await page.getByRole("button", { name: "Save post" }).click(); + await expect(page).toHaveURL(/\/admin\/posts\/\d+\/edit\?saved=1/); + + await page.goto("/posts/editor-capabilities-check"); + await expect( + page.locator(".markdown-body").getByRole("heading", { level: 2, name: "Pasted heading" }), + ).toBeVisible(); + await expect(page.locator(".markdown-body strong")).toContainText("pasted bold"); + const publicBody = await page.locator(".markdown-body").innerHTML(); + expect(publicBody).not.toContain(" { + // Seeded defaults. + await page.goto("/"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "solarized-dark"); + await expect(page.locator("html")).toHaveAttribute("data-font", "geist"); + + await page.goto("/admin/login"); + await page.getByLabel("Username").fill(ADMIN_USERNAME); + await page.getByLabel("Password").fill(ADMIN_PASSWORD); + await page.getByRole("button", { name: "Sign in" }).click(); + await expect(page).toHaveURL(/\/admin$/); + + // Switch to Dracula + Lora. + await page.goto("/admin/settings"); + await page.getByRole("radio", { name: "Dracula" }).check(); + await page.getByRole("radio", { name: "Lora" }).check(); + await page.getByRole("button", { name: "Save settings" }).click(); + await expect(page.getByText("Settings saved.")).toBeVisible(); + + // Public site picks up both; body background is Dracula's #282a36 and + // the computed font stack leads with Lora. + await page.goto("/"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dracula"); + await expect(page.locator("html")).toHaveAttribute("data-font", "lora"); + await expect(page.locator("body")).toHaveCSS("background-color", "rgb(40, 42, 54)"); + await expect(page.locator("body")).toHaveCSS("font-family", /Lora/); + + // The admin area follows the same appearance settings. + await page.goto("/admin/settings"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "dracula"); + + // Black & White renders plain white. + await page.getByRole("radio", { name: "Black & White" }).check(); + await page.getByRole("button", { name: "Save settings" }).click(); + await expect(page.getByText("Settings saved.")).toBeVisible(); + await page.goto("/"); + await expect(page.locator("body")).toHaveCSS("background-color", "rgb(255, 255, 255)"); + + // One of the newer palettes + fonts: Tokyo Night with Space Grotesk. + await page.goto("/admin/settings"); + await page.getByRole("radio", { name: "Tokyo Night" }).check(); + await page.getByRole("radio", { name: "Space Grotesk" }).check(); + await page.getByRole("button", { name: "Save settings" }).click(); + await expect(page.getByText("Settings saved.")).toBeVisible(); + await page.goto("/"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "tokyo-night"); + await expect(page.locator("body")).toHaveCSS("background-color", "rgb(26, 27, 38)"); + await expect(page.locator("body")).toHaveCSS("font-family", /Space Grotesk/); + + // And back to the seeded defaults. + await page.goto("/admin/settings"); + await page.getByRole("radio", { name: "Solarized Dark" }).check(); + await page.getByRole("radio", { name: /^Geist/ }).check(); + await page.getByRole("button", { name: "Save settings" }).click(); + await expect(page.getByText("Settings saved.")).toBeVisible(); + + await page.goto("/"); + await expect(page.locator("html")).toHaveAttribute("data-theme", "solarized-dark"); + await expect(page.locator("html")).toHaveAttribute("data-font", "geist"); + await expect(page.locator("body")).toHaveCSS("background-color", "rgb(0, 43, 54)"); +}); diff --git a/tests/e2e/setup-db.ts b/tests/e2e/setup-db.ts new file mode 100644 index 0000000..3d86548 --- /dev/null +++ b/tests/e2e/setup-db.ts @@ -0,0 +1,39 @@ +import "dotenv/config"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Pool } from "pg"; +import { seed } from "../../scripts/seed"; + +/** + * Resets the dedicated E2E database and seeds it. Runs as a standalone + * step BEFORE `playwright test` (see the test:e2e script) because + * Playwright boots the web server before globalSetup would run. + */ +async function main() { + const url = + process.env.E2E_DATABASE_URL || "postgresql://blog:blog@localhost:5434/blog_e2e"; + const pool = new Pool({ connectionString: url, max: 1 }); + try { + await pool.query("select 1"); + } catch (error) { + await pool.end(); + throw new Error( + `Could not reach the E2E database at ${url}.\n` + + `Start it with: docker compose up -d\n(${String(error)})`, + ); + } + // The drizzle schema holds the migration journal — drop it too, or the + // migrator will consider everything applied against the empty schema. + await pool.query( + "DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP SCHEMA IF EXISTS drizzle CASCADE;", + ); + await migrate(drizzle(pool), { migrationsFolder: "./drizzle" }); + await pool.end(); + await seed(url, (msg) => console.log(`[e2e-setup] ${msg}`)); + console.log("[e2e-setup] database ready"); +} + +main().catch((error) => { + console.error("[e2e-setup] failed:", error); + process.exit(1); +}); diff --git a/tests/global-setup.ts b/tests/global-setup.ts new file mode 100644 index 0000000..1f4a267 --- /dev/null +++ b/tests/global-setup.ts @@ -0,0 +1,27 @@ +import "dotenv/config"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate } from "drizzle-orm/node-postgres/migrator"; +import { Pool } from "pg"; + +/** Recreates the test schema from migrations before every vitest run. */ +export default async function globalSetup() { + const url = + process.env.TEST_DATABASE_URL || "postgresql://blog:blog@localhost:5434/blog_test"; + const pool = new Pool({ connectionString: url, max: 1 }); + try { + await pool.query("select 1"); + } catch (error) { + await pool.end(); + throw new Error( + `Could not reach the test database at ${url}.\n` + + `Start it with: docker compose up -d\n(${String(error)})`, + ); + } + // The drizzle schema holds the migration journal — drop it too, or the + // migrator will consider everything applied against the empty schema. + await pool.query( + "DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP SCHEMA IF EXISTS drizzle CASCADE;", + ); + await migrate(drizzle(pool), { migrationsFolder: "./drizzle" }); + await pool.end(); +} diff --git a/tests/helpers/db.ts b/tests/helpers/db.ts new file mode 100644 index 0000000..22df93a --- /dev/null +++ b/tests/helpers/db.ts @@ -0,0 +1,9 @@ +import { sql } from "drizzle-orm"; +import { db } from "@/db"; + +/** Wipes every table between tests; identities restart at 1. */ +export async function resetDb(): Promise { + await db.execute( + sql`TRUNCATE users, sessions, posts, tags, post_tags, pages, nav_items, settings RESTART IDENTITY CASCADE`, + ); +} diff --git a/tests/integration/auth.test.ts b/tests/integration/auth.test.ts new file mode 100644 index 0000000..bd5e60a --- /dev/null +++ b/tests/integration/auth.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { sessions, users } from "@/db/schema"; +import { hashPassword, verifyPassword } from "@/lib/auth/password"; +import { + createSession, + deleteSession, + validateSessionToken, +} from "@/lib/auth/session"; +import { resetDb } from "../helpers/db"; + +beforeEach(resetDb); + +async function insertUser() { + const [user] = await db + .insert(users) + .values({ username: "admin", passwordHash: await hashPassword("correct horse") }) + .returning(); + return user; +} + +describe("password hashing", () => { + it("verifies the original password and rejects others", async () => { + const hash = await hashPassword("s3cret-passphrase"); + expect(hash.startsWith("scrypt$")).toBe(true); + expect(hash).not.toContain("s3cret-passphrase"); + expect(await verifyPassword(hash, "s3cret-passphrase")).toBe(true); + expect(await verifyPassword(hash, "wrong")).toBe(false); + }); + + it("produces unique salts per hash", async () => { + expect(await hashPassword("same")).not.toBe(await hashPassword("same")); + }); + + it("rejects malformed stored hashes without throwing", async () => { + expect(await verifyPassword("garbage", "x")).toBe(false); + expect(await verifyPassword("scrypt$bad$data", "x")).toBe(false); + }); +}); + +describe("sessions", () => { + it("round-trips a valid session token", async () => { + const user = await insertUser(); + const { token, expiresAt } = await createSession(user.id); + expect(expiresAt.getTime()).toBeGreaterThan(Date.now()); + + const sessionUser = await validateSessionToken(token); + expect(sessionUser).toEqual({ id: user.id, username: "admin" }); + + // Only a hash of the token is stored. + const rows = await db.select().from(sessions); + expect(rows).toHaveLength(1); + expect(rows[0].id).not.toBe(token); + }); + + it("rejects unknown tokens", async () => { + await insertUser(); + expect(await validateSessionToken("not-a-real-token")).toBeNull(); + expect(await validateSessionToken("")).toBeNull(); + }); + + it("treats expired sessions as absent and deletes them", async () => { + const user = await insertUser(); + const { token } = await createSession(user.id); + await db + .update(sessions) + .set({ expiresAt: new Date(Date.now() - 1000) }) + .where(eq(sessions.userId, user.id)); + + expect(await validateSessionToken(token)).toBeNull(); + expect(await db.select().from(sessions)).toHaveLength(0); + }); + + it("invalidates a session on logout", async () => { + const user = await insertUser(); + const { token } = await createSession(user.id); + await deleteSession(token); + expect(await validateSessionToken(token)).toBeNull(); + }); +}); diff --git a/tests/integration/home.test.ts b/tests/integration/home.test.ts new file mode 100644 index 0000000..9b0c453 --- /dev/null +++ b/tests/integration/home.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { fontEnum, tags, themeEnum } from "@/db/schema"; +import { resolveHomeContent } from "@/lib/services/home"; +import { createPage, deletePage, setPageStatus } from "@/lib/services/pages"; +import { createPost } from "@/lib/services/posts"; +import { + getSettings, + listPublicNav, + saveSettings, + type SettingsInput, +} from "@/lib/services/settings"; +import { resetDb } from "../helpers/db"; + +const settingsInput = (overrides: Partial = {}): SettingsInput => ({ + siteTitle: "Test Site", + headerText: "", + footerText: "", + postsPerPage: 10, + excerptWords: 40, + homeMode: "posts", + homeTagId: null, + homePageId: null, + theme: "solarized-dark", + font: "geist", + ...overrides, +}); + +beforeEach(resetDb); + +describe("home page configuration", () => { + it("defaults to the post list", async () => { + await saveSettings(settingsInput(), []); + const home = await resolveHomeContent(await getSettings()); + expect(home.kind).toBe("posts"); + }); + + it("shows a configured tag and falls back when the tag is deleted", async () => { + await createPost({ + title: "Tagged", + slug: "", + body: "b", + authorName: "T", + featuredImageUrl: null, + featuredImageAlt: null, + status: "published", + tagIds: [], + newTagNames: ["Featured"], + }); + const [tag] = await db.select().from(tags).where(eq(tags.slug, "featured")); + await saveSettings(settingsInput({ homeMode: "tag", homeTagId: tag.id }), []); + + const home = await resolveHomeContent(await getSettings()); + expect(home).toMatchObject({ kind: "tag", tag: { slug: "featured" } }); + + // Deleting the tag nulls settings.home_tag_id via ON DELETE SET NULL. + await db.delete(tags).where(eq(tags.id, tag.id)); + const settingsAfter = await getSettings(); + expect(settingsAfter.homeTagId).toBeNull(); + expect((await resolveHomeContent(settingsAfter)).kind).toBe("posts"); + }); + + it("shows a configured page and falls back when it is unpublished or deleted", async () => { + const page = await createPage({ + title: "Welcome", + slug: "", + body: "Hello!", + status: "published", + }); + await saveSettings(settingsInput({ homeMode: "page", homePageId: page.id }), []); + + let home = await resolveHomeContent(await getSettings()); + expect(home).toMatchObject({ kind: "page", page: { slug: "welcome" } }); + + // Unpublished page must not leak through the home route. + await setPageStatus(page.id, "draft"); + home = await resolveHomeContent(await getSettings()); + expect(home.kind).toBe("posts"); + + await setPageStatus(page.id, "published"); + await deletePage(page.id); + const settingsAfter = await getSettings(); + expect(settingsAfter.homePageId).toBeNull(); + expect((await resolveHomeContent(settingsAfter)).kind).toBe("posts"); + }); + + it("ignores a stale tag reference when the mode is posts", async () => { + await saveSettings(settingsInput({ homeMode: "posts", homeTagId: null }), []); + expect((await resolveHomeContent(await getSettings())).kind).toBe("posts"); + }); +}); + +describe("appearance settings", () => { + it("defaults to solarized-dark and geist on an unseeded database", async () => { + const settings = await getSettings(); + expect(settings.theme).toBe("solarized-dark"); + expect(settings.font).toBe("geist"); + }); + + it("round-trips every theme in the enum", async () => { + for (const theme of themeEnum.enumValues) { + await saveSettings(settingsInput({ theme }), []); + expect((await getSettings()).theme).toBe(theme); + } + }); + + it("round-trips every font in the enum", async () => { + for (const font of fontEnum.enumValues) { + await saveSettings(settingsInput({ font }), []); + expect((await getSettings()).font).toBe(font); + } + }); +}); + +describe("navigation", () => { + it("keeps URL items, resolves page items, and hides unpublished pages", async () => { + const page = await createPage({ + title: "About", + slug: "", + body: "", + status: "published", + }); + await saveSettings(settingsInput(), [ + { label: "All posts", url: "/posts", pageId: null }, + { label: "About", url: null, pageId: page.id }, + { label: "Elsewhere", url: "https://example.com", pageId: null }, + ]); + + let nav = await listPublicNav(); + expect(nav).toEqual([ + { label: "All posts", href: "/posts", external: false }, + { label: "About", href: "/pages/about", external: false }, + { label: "Elsewhere", href: "https://example.com", external: true }, + ]); + + await setPageStatus(page.id, "draft"); + nav = await listPublicNav(); + expect(nav.map((l) => l.label)).toEqual(["All posts", "Elsewhere"]); + + // Deleting the page removes its nav item entirely (ON DELETE CASCADE). + await setPageStatus(page.id, "published"); + await deletePage(page.id); + nav = await listPublicNav(); + expect(nav.map((l) => l.label)).toEqual(["All posts", "Elsewhere"]); + }); +}); diff --git a/tests/integration/posts.test.ts b/tests/integration/posts.test.ts new file mode 100644 index 0000000..7042433 --- /dev/null +++ b/tests/integration/posts.test.ts @@ -0,0 +1,99 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { PostInput } from "@/lib/services/posts"; +import { + createPost, + getPublishedPostBySlug, + listPublishedPosts, + setPostStatus, +} from "@/lib/services/posts"; +import { resetDb } from "../helpers/db"; + +const input = (overrides: Partial = {}): PostInput => ({ + title: "A Post", + slug: "", + body: "Some body text for the post.", + authorName: "Tester", + featuredImageUrl: null, + featuredImageAlt: null, + status: "draft", + tagIds: [], + newTagNames: [], + ...overrides, +}); + +beforeEach(resetDb); + +describe("public post queries", () => { + it("excludes draft posts from listings and slug lookups", async () => { + await createPost(input({ title: "Published one", status: "published" })); + const draft = await createPost(input({ title: "Hidden draft", status: "draft" })); + + const page = await listPublishedPosts({ page: 1, perPage: 10 }); + expect(page.total).toBe(1); + expect(page.items.map((p) => p.title)).toEqual(["Published one"]); + + expect(await getPublishedPostBySlug(draft.slug)).toBeNull(); + }); + + it("orders posts in reverse chronological order of publication", async () => { + await createPost(input({ title: "Oldest", status: "published" })); + await createPost(input({ title: "Middle", status: "published" })); + await createPost(input({ title: "Newest", status: "published" })); + + const page = await listPublishedPosts({ page: 1, perPage: 10 }); + expect(page.items.map((p) => p.title)).toEqual(["Newest", "Middle", "Oldest"]); + }); + + it("paginates with correct totals", async () => { + for (let i = 1; i <= 7; i++) { + await createPost(input({ title: `Post ${i}`, status: "published" })); + } + + const first = await listPublishedPosts({ page: 1, perPage: 5 }); + expect(first.items).toHaveLength(5); + expect(first.total).toBe(7); + expect(first.pageCount).toBe(2); + + const second = await listPublishedPosts({ page: 2, perPage: 5 }); + expect(second.items).toHaveLength(2); + expect(second.items.map((p) => p.title)).toEqual(["Post 2", "Post 1"]); + }); +}); + +describe("creating and publishing a post", () => { + it("keeps a new draft private, then makes it public on publish", async () => { + const draft = await createPost(input({ title: "Launch notes" })); + expect(draft.publishedAt).toBeNull(); + expect(await getPublishedPostBySlug("launch-notes")).toBeNull(); + + const published = await setPostStatus(draft.id, "published"); + expect(published?.status).toBe("published"); + expect(published?.publishedAt).toBeInstanceOf(Date); + + const publicPost = await getPublishedPostBySlug("launch-notes"); + expect(publicPost?.title).toBe("Launch notes"); + + const listing = await listPublishedPosts({ page: 1, perPage: 10 }); + expect(listing.items.map((p) => p.slug)).toContain("launch-notes"); + }); + + it("stamps publishedAt once and keeps it across unpublish/republish", async () => { + const post = await createPost(input({ status: "published" })); + const firstDate = post.publishedAt; + expect(firstDate).toBeInstanceOf(Date); + + const unpublished = await setPostStatus(post.id, "draft"); + expect(unpublished?.publishedAt?.getTime()).toBe(firstDate?.getTime()); + + const republished = await setPostStatus(post.id, "published"); + expect(republished?.publishedAt?.getTime()).toBe(firstDate?.getTime()); + }); + + it("attaches existing and newly created tags", async () => { + const post = await createPost( + input({ status: "published", newTagNames: ["Fresh Tag", "Another"] }), + ); + const publicPost = await getPublishedPostBySlug(post.slug); + expect(publicPost?.tags.map((t) => t.slug).sort()).toEqual(["another", "fresh-tag"]); + }); +}); diff --git a/tests/integration/slugs.test.ts b/tests/integration/slugs.test.ts new file mode 100644 index 0000000..6d5dd04 --- /dev/null +++ b/tests/integration/slugs.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { SlugConflictError } from "@/lib/services/errors"; +import type { PostInput } from "@/lib/services/posts"; +import { createPost, updatePost } from "@/lib/services/posts"; +import { createPage } from "@/lib/services/pages"; +import { resetDb } from "../helpers/db"; + +const input = (overrides: Partial = {}): PostInput => ({ + title: "My First Post", + slug: "", + body: "body", + authorName: "Tester", + featuredImageUrl: null, + featuredImageAlt: null, + status: "draft", + tagIds: [], + newTagNames: [], + ...overrides, +}); + +beforeEach(resetDb); + +describe("post slug generation", () => { + it("derives the slug from the title when left blank", async () => { + const post = await createPost(input()); + expect(post.slug).toBe("my-first-post"); + }); + + it("auto-suffixes generated slugs on duplicate titles", async () => { + const a = await createPost(input()); + const b = await createPost(input()); + const c = await createPost(input()); + expect(a.slug).toBe("my-first-post"); + expect(b.slug).toBe("my-first-post-2"); + expect(c.slug).toBe("my-first-post-3"); + }); + + it("normalizes an explicitly chosen slug", async () => { + const post = await createPost(input({ slug: " My Custom SLUG! " })); + expect(post.slug).toBe("my-custom-slug"); + }); + + it("rejects an explicit slug that another post owns", async () => { + await createPost(input({ slug: "taken" })); + await expect(createPost(input({ slug: "taken" }))).rejects.toBeInstanceOf( + SlugConflictError, + ); + }); + + it("lets a post keep its own slug on update", async () => { + const post = await createPost(input({ slug: "keeper" })); + const updated = await updatePost(post.id, input({ slug: "keeper", title: "New title" })); + expect(updated?.slug).toBe("keeper"); + expect(updated?.title).toBe("New title"); + }); + + it("rejects updating to a slug owned by another post", async () => { + await createPost(input({ slug: "occupied" })); + const other = await createPost(input({ slug: "elsewhere" })); + await expect(updatePost(other.id, input({ slug: "occupied" }))).rejects.toBeInstanceOf( + SlugConflictError, + ); + }); + + it("regenerates from the title when the slug is cleared on update", async () => { + const post = await createPost(input({ slug: "old-slug" })); + const updated = await updatePost(post.id, input({ slug: "", title: "Renamed Post" })); + expect(updated?.slug).toBe("renamed-post"); + }); +}); + +describe("page slugs", () => { + it("share the same policy but live in their own namespace", async () => { + const page = await createPage({ title: "About", slug: "", body: "", status: "draft" }); + expect(page.slug).toBe("about"); + // A post may use "about" too — pages and posts have separate URL spaces. + const post = await createPost(input({ slug: "about" })); + expect(post.slug).toBe("about"); + await expect( + createPage({ title: "About", slug: "about", body: "", status: "draft" }), + ).rejects.toBeInstanceOf(SlugConflictError); + }); +}); diff --git a/tests/integration/tags.test.ts b/tests/integration/tags.test.ts new file mode 100644 index 0000000..50ae08b --- /dev/null +++ b/tests/integration/tags.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { db } from "@/db"; +import { tags } from "@/db/schema"; +import type { PostInput } from "@/lib/services/posts"; +import { createPost, listPublishedPosts } from "@/lib/services/posts"; +import { getPublicTagBySlug, listPublicTags } from "@/lib/services/tags"; +import { resetDb } from "../helpers/db"; + +const input = (overrides: Partial = {}): PostInput => ({ + title: `Post ${Math.random().toString(36).slice(2, 8)}`, + slug: "", + body: "body", + authorName: "Tester", + featuredImageUrl: null, + featuredImageAlt: null, + status: "published", + tagIds: [], + newTagNames: [], + ...overrides, +}); + +beforeEach(resetDb); + +describe("public tag visibility", () => { + it("lists only tags used by at least one published post", async () => { + await createPost(input({ newTagNames: ["Visible"] })); + await createPost(input({ status: "draft", newTagNames: ["Draft Only"] })); + await db.insert(tags).values({ name: "Unused", slug: "unused" }); + + const publicTags = await listPublicTags(); + expect(publicTags.map((t) => t.slug)).toEqual(["visible"]); + expect(publicTags[0].postCount).toBe(1); + }); + + it("resolves tag slugs only when they have published posts", async () => { + await createPost(input({ status: "draft", newTagNames: ["Ghost"] })); + expect(await getPublicTagBySlug("ghost")).toBeNull(); + + await createPost(input({ newTagNames: ["Real"] })); + const tag = await getPublicTagBySlug("real"); + expect(tag?.name).toBe("Real"); + expect(tag?.postCount).toBe(1); + }); +}); + +describe("tag filtering", () => { + it("returns only published posts carrying the tag", async () => { + await createPost(input({ title: "Tagged A", newTagNames: ["Filter Me"] })); + await createPost(input({ title: "Tagged B", newTagNames: ["Filter Me"] })); + await createPost(input({ title: "Other tag", newTagNames: ["Different"] })); + await createPost(input({ title: "No tags at all" })); + await createPost( + input({ title: "Draft with tag", status: "draft", newTagNames: ["Filter Me"] }), + ); + + const tag = await getPublicTagBySlug("filter-me"); + expect(tag).not.toBeNull(); + expect(tag?.postCount).toBe(2); + + const filtered = await listPublishedPosts({ page: 1, perPage: 10, tagId: tag!.id }); + expect(filtered.total).toBe(2); + expect(filtered.items.map((p) => p.title).sort()).toEqual(["Tagged A", "Tagged B"]); + }); + + it("paginates within a tag", async () => { + for (let i = 1; i <= 6; i++) { + await createPost(input({ title: `Series ${i}`, newTagNames: ["Series"] })); + } + const tag = await getPublicTagBySlug("series"); + const page2 = await listPublishedPosts({ page: 2, perPage: 4, tagId: tag!.id }); + expect(page2.items).toHaveLength(2); + expect(page2.pageCount).toBe(2); + }); + + it("reuses one tag row when the same name is added twice", async () => { + await createPost(input({ newTagNames: ["Shared"] })); + await createPost(input({ newTagNames: ["shared"] })); // same slug after normalizing + const publicTags = await listPublicTags(); + expect(publicTags).toHaveLength(1); + expect(publicTags[0].postCount).toBe(2); + }); +}); diff --git a/tests/setup-env.ts b/tests/setup-env.ts new file mode 100644 index 0000000..f186888 --- /dev/null +++ b/tests/setup-env.ts @@ -0,0 +1,12 @@ +import "dotenv/config"; +import { afterAll } from "vitest"; + +// Runs before each test file's imports: point the app's db module at the +// test database instead of the development one. +process.env.DATABASE_URL = + process.env.TEST_DATABASE_URL || "postgresql://blog:blog@localhost:5434/blog_test"; + +afterAll(async () => { + const { pool } = await import("@/db"); + await pool.end(); +}); diff --git a/tests/unit/excerpt.test.ts b/tests/unit/excerpt.test.ts new file mode 100644 index 0000000..30977f3 --- /dev/null +++ b/tests/unit/excerpt.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from "vitest"; +import { generateExcerpt } from "@/lib/excerpt"; + +describe("generateExcerpt (HTML bodies)", () => { + it("strips tags but keeps the visible text", () => { + const html = + "

Heading

Some bold text with a link. And more.

"; + expect(generateExcerpt(html, 100)).toBe("Heading Some bold text with a link. And more."); + }); + + it("caps the excerpt at the word limit and appends an ellipsis", () => { + expect(generateExcerpt("

one two three four five six

", 3)).toBe("one two three…"); + }); + + it("adds no ellipsis when the text fits exactly", () => { + expect(generateExcerpt("

one two three

", 3)).toBe("one two three"); + }); + + it("skips code blocks entirely", () => { + const html = "
const hidden = true;

After the code.

"; + expect(generateExcerpt(html, 50)).toBe("After the code."); + }); + + it("contributes nothing from images (alt text is not visible text)", () => { + const html = '

decorative alt

Caption text.

'; + expect(generateExcerpt(html, 50)).toBe("Caption text."); + }); + + it("keeps inline code as text", () => { + expect(generateExcerpt("

Run npm test daily.

", 50)).toBe( + "Run npm test daily.", + ); + }); + + it("separates list items and table cells with whitespace", () => { + expect(generateExcerpt("
  • first item
  • second item
", 50)).toBe( + "first item second item", + ); + expect( + generateExcerpt("
cell onecell two
", 50), + ).toBe("cell one cell two"); + }); + + it("returns an empty string for empty or non-textual bodies", () => { + expect(generateExcerpt("", 40)).toBe(""); + expect(generateExcerpt('

x

', 40)).toBe(""); + }); + + it("tolerates a nonsensical word limit", () => { + expect(generateExcerpt("

alpha beta

", 0)).toBe("alpha…"); + }); +}); diff --git a/tests/unit/html.test.ts b/tests/unit/html.test.ts new file mode 100644 index 0000000..396abb3 --- /dev/null +++ b/tests/unit/html.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeHtml } from "@/lib/html"; + +describe("sanitizeHtml (stored editor bodies)", () => { + it("strips

After

'); + expect(html).not.toContain(" { + const html = sanitizeHtml(''); + expect(html).not.toContain("onerror"); + expect(html).toContain('src="/uploads/x.png"'); + }); + + it("removes javascript: URLs from links", () => { + const html = sanitizeHtml('click me'); + expect(html).not.toContain("javascript:"); + expect(html).toContain("click me"); + }); + + it("keeps the marks the editor produces, including underline", () => { + const html = sanitizeHtml("

under gone bold

"); + expect(html).toContain("under"); + expect(html).toContain("gone"); + expect(html).toContain("bold"); + }); + + it("keeps relative upload URLs and http(s) images", () => { + expect(sanitizeHtml('text')).toContain( + '/uploads/a.png', + ); + expect(sanitizeHtml('')).toContain( + "https://example.com/b.jpg", + ); + }); + + it("keeps table structure", () => { + const html = sanitizeHtml( + "
h
d
", + ); + expect(html).toContain(""); + expect(html).toContain(""); + expect(html).toContain(""); + }); + + it("drops iframes and style tags", () => { + const html = sanitizeHtml('

ok

'); + expect(html).not.toContain("iframe"); + expect(html).not.toContain(" { + it("renders basic markdown", () => { + const html = renderMarkdown("Some **bold** and *italic* text."); + expect(html).toContain("bold"); + expect(html).toContain("italic"); + }); + + it("renders GFM tables and fenced code", () => { + const html = renderMarkdown("| a | b |\n| - | - |\n| 1 | 2 |\n\n```\ncode\n```"); + expect(html).toContain("
hd
"); + expect(html).toContain("
");
+  });
+
+  it("strips \n\nAfter');
+    expect(html).not.toContain(" {
+    const html = renderMarkdown('');
+    expect(html).not.toContain("onerror");
+    expect(html).not.toContain("alert(1)");
+  });
+
+  it("removes javascript: URLs from links", () => {
+    const html = renderMarkdown("[click me](javascript:alert(1))");
+    expect(html).not.toContain("javascript:");
+    expect(html).toContain("click me");
+  });
+
+  it("keeps safe inline HTML like ", () => {
+    const html = renderMarkdown("Press Tab to move.");
+    expect(html).toContain("Tab");
+  });
+});
diff --git a/tests/unit/pagination.test.ts b/tests/unit/pagination.test.ts
new file mode 100644
index 0000000..b13faca
--- /dev/null
+++ b/tests/unit/pagination.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import { pageCountFor, parsePage } from "@/lib/pagination";
+import { isValidLinkUrl } from "@/lib/validation";
+
+describe("parsePage", () => {
+  it("parses plain positive integers", () => {
+    expect(parsePage("1")).toBe(1);
+    expect(parsePage("42")).toBe(42);
+  });
+
+  it("collapses missing values to page 1", () => {
+    expect(parsePage(undefined)).toBe(1);
+    expect(parsePage("")).toBe(1);
+  });
+
+  it("collapses malformed values to page 1", () => {
+    expect(parsePage("abc")).toBe(1);
+    expect(parsePage("-3")).toBe(1);
+    expect(parsePage("0")).toBe(1);
+    expect(parsePage("1.5")).toBe(1);
+    expect(parsePage("1e3")).toBe(1);
+    expect(parsePage("2abc")).toBe(1);
+  });
+
+  it("uses the first entry of repeated params", () => {
+    expect(parsePage(["3", "9"])).toBe(3);
+  });
+
+  it("caps absurdly large values", () => {
+    expect(parsePage("99999999999999")).toBe(100_000);
+  });
+});
+
+describe("pageCountFor", () => {
+  it("computes ceilings and never returns less than one page", () => {
+    expect(pageCountFor(0, 5)).toBe(1);
+    expect(pageCountFor(5, 5)).toBe(1);
+    expect(pageCountFor(6, 5)).toBe(2);
+  });
+});
+
+describe("isValidLinkUrl", () => {
+  it("accepts http(s) URLs and site-relative paths", () => {
+    expect(isValidLinkUrl("https://example.com/x")).toBe(true);
+    expect(isValidLinkUrl("http://example.com")).toBe(true);
+    expect(isValidLinkUrl("/posts")).toBe(true);
+  });
+
+  it("rejects other schemes and protocol-relative URLs", () => {
+    expect(isValidLinkUrl("javascript:alert(1)")).toBe(false);
+    expect(isValidLinkUrl("ftp://example.com")).toBe(false);
+    expect(isValidLinkUrl("//evil.example")).toBe(false);
+    expect(isValidLinkUrl("not a url")).toBe(false);
+  });
+});
diff --git a/tests/unit/slug.test.ts b/tests/unit/slug.test.ts
new file mode 100644
index 0000000..9738aba
--- /dev/null
+++ b/tests/unit/slug.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import { ensureUniqueSlug, slugify } from "@/lib/slug";
+
+describe("slugify", () => {
+  it("lowercases and hyphenates plain titles", () => {
+    expect(slugify("Hello, World!")).toBe("hello-world");
+  });
+
+  it("collapses runs of spaces and punctuation into single hyphens", () => {
+    expect(slugify("  Multiple   spaces &&& symbols!! ")).toBe("multiple-spaces-symbols");
+  });
+
+  it("strips diacritics", () => {
+    expect(slugify("Crème Brûlée à Paris")).toBe("creme-brulee-a-paris");
+  });
+
+  it("drops emoji and other non-latin symbols", () => {
+    expect(slugify("🎉 Party time 🎉")).toBe("party-time");
+  });
+
+  it("keeps digits", () => {
+    expect(slugify("Top 10 Things")).toBe("top-10-things");
+  });
+
+  it("returns an empty string when nothing survives", () => {
+    expect(slugify("!!! ***")).toBe("");
+  });
+
+  it("truncates very long titles without a trailing hyphen", () => {
+    const slug = slugify(`${"word ".repeat(40)}end`);
+    expect(slug.length).toBeLessThanOrEqual(96);
+    expect(slug.endsWith("-")).toBe(false);
+  });
+});
+
+describe("ensureUniqueSlug", () => {
+  const takenSet = (...taken: string[]) => {
+    const set = new Set(taken);
+    return async (slug: string) => set.has(slug);
+  };
+
+  it("returns the base slug when free", async () => {
+    expect(await ensureUniqueSlug("my-post", takenSet())).toBe("my-post");
+  });
+
+  it("appends -2 on the first conflict", async () => {
+    expect(await ensureUniqueSlug("my-post", takenSet("my-post"))).toBe("my-post-2");
+  });
+
+  it("keeps counting past multiple conflicts", async () => {
+    expect(
+      await ensureUniqueSlug("my-post", takenSet("my-post", "my-post-2", "my-post-3")),
+    ).toBe("my-post-4");
+  });
+
+  it("falls back to 'untitled' for an empty base", async () => {
+    expect(await ensureUniqueSlug("", takenSet())).toBe("untitled");
+    expect(await ensureUniqueSlug("", takenSet("untitled"))).toBe("untitled-2");
+  });
+});
diff --git a/tests/unit/uploads.test.ts b/tests/unit/uploads.test.ts
new file mode 100644
index 0000000..b27927b
--- /dev/null
+++ b/tests/unit/uploads.test.ts
@@ -0,0 +1,65 @@
+import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { MAX_UPLOAD_BYTES, saveUploadedImage } from "@/lib/uploads";
+
+const PNG_BYTES = Buffer.from(
+  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+  "base64",
+);
+
+const originalCwd = process.cwd();
+let rootDir: string;
+let uploadsDir: string;
+beforeEach(async () => {
+  rootDir = await mkdtemp(path.join(tmpdir(), "blog-uploads-"));
+  uploadsDir = path.join(rootDir, "uploads");
+  process.chdir(rootDir);
+});
+afterEach(async () => {
+  process.chdir(originalCwd);
+  await rm(rootDir, { recursive: true, force: true });
+});
+
+describe("saveUploadedImage", () => {
+  it("saves a valid image under a random name with the right extension", async () => {
+    const file = new File([PNG_BYTES], "user chosen name!!.png", { type: "image/png" });
+    const result = await saveUploadedImage(file);
+    expect(result.ok).toBe(true);
+    if (!result.ok) return;
+
+    expect(result.filename).toMatch(/^[0-9a-f-]{36}\.png$/);
+    const written = await stat(path.join(uploadsDir, result.filename));
+    expect(written.size).toBe(PNG_BYTES.length);
+    // The client-supplied filename never reaches the filesystem.
+    expect(await readdir(uploadsDir)).toEqual([result.filename]);
+  });
+
+  it("maps jpeg MIME to a .jpg extension", async () => {
+    const file = new File([PNG_BYTES], "x", { type: "image/jpeg" });
+    const result = await saveUploadedImage(file);
+    expect(result.ok && result.filename.endsWith(".jpg")).toBe(true);
+  });
+
+  it("rejects non-image MIME types", async () => {
+    const file = new File([""], "evil.svg", { type: "image/svg+xml" });
+    const result = await saveUploadedImage(file);
+    expect(result).toMatchObject({ ok: false, status: 400 });
+    await expect(readdir(uploadsDir)).rejects.toMatchObject({ code: "ENOENT" });
+  });
+
+  it("rejects files over the size limit", async () => {
+    const big = new File([Buffer.alloc(MAX_UPLOAD_BYTES + 1)], "big.png", {
+      type: "image/png",
+    });
+    const result = await saveUploadedImage(big);
+    expect(result).toMatchObject({ ok: false, status: 413 });
+  });
+
+  it("rejects empty files", async () => {
+    const empty = new File([], "empty.png", { type: "image/png" });
+    const result = await saveUploadedImage(empty);
+    expect(result).toMatchObject({ ok: false, status: 400 });
+  });
+});
diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json
new file mode 100644
index 0000000..6643a98
--- /dev/null
+++ b/tsconfig.typecheck.json
@@ -0,0 +1,11 @@
+{
+  "extends": "./tsconfig.json",
+  "include": [
+    "next-env.d.ts",
+    "**/*.ts",
+    "**/*.tsx",
+    ".next/types/**/*.ts",
+    "**/*.mts"
+  ],
+  "exclude": ["node_modules", ".next/dev"]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..a56213d
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,18 @@
+import { fileURLToPath } from "node:url";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+  resolve: {
+    alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
+  },
+  test: {
+    environment: "node",
+    include: ["tests/unit/**/*.test.ts", "tests/integration/**/*.test.ts"],
+    globalSetup: ["./tests/global-setup.ts"],
+    setupFiles: ["./tests/setup-env.ts"],
+    // Integration tests share one Postgres database; run files serially.
+    fileParallelism: false,
+    testTimeout: 20_000,
+    hookTimeout: 30_000,
+  },
+});