diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ee48d1b --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Database +DATABASE_URL="postgresql://user:password@host/db?sslmode=require" + +# Auth +AUTH_URL="http://localhost:3000" +AUTH_SECRET="" # openssl rand -base64 32 +AUTH_GITHUB_ID="" +AUTH_GITHUB_SECRET="" + +# Email +AUTH_RESEND_KEY="" +EMAIL_FROM="noreply@yourdomain.com" + +# App +NEXT_PUBLIC_APP_URL="http://localhost:3000" + +# Demo +DEMO_MODE="true" +CRON_SECRET="" # openssl rand -base64 32 \ No newline at end of file diff --git a/.env.test.example b/.env.test.example new file mode 100644 index 0000000..06f2e77 --- /dev/null +++ b/.env.test.example @@ -0,0 +1,11 @@ +NODE_ENV=production +DATABASE_URL="postgresql://test:test@localhost:5432/feedbackflow_test" +AUTH_SECRET="test-secret-test-secret-test-secret-test" +AUTH_GITHUB_ID="test" +AUTH_GITHUB_SECRET="test" +AUTH_RESEND_KEY="test" +EMAIL_FROM="test@test.com" +PORT=3001 +AUTH_URL="http://localhost:3001" +NEXT_PUBLIC_APP_URL="http://localhost:3001" +E2E_TEST_MODE="true" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 86e4fe0..4d65fe7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,3 +27,33 @@ jobs: AUTH_RESEND_KEY: fake EMAIL_FROM: fake@example.com NEXT_PUBLIC_APP_URL: http://localhost:3000 + e2e: + runs-on: ubuntu-latest + env: + DATABASE_URL: ${{ secrets.E2E_DATABASE_URL }} + AUTH_SECRET: test-secret-test-secret-test-secret-test + AUTH_GITHUB_ID: fake + AUTH_GITHUB_SECRET: fake + AUTH_RESEND_KEY: fake + EMAIL_FROM: fake@example.com + NEXT_PUBLIC_APP_URL: http://localhost:3001 + E2E_TEST_MODE: "true" + PORT: "3001" + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npx prisma migrate deploy + - run: npm run build + - run: npx playwright test + - if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index fa5a330..86091a6 100644 --- a/.gitignore +++ b/.gitignore @@ -32,8 +32,10 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# env files .env* +!.env.example +!.env.test.example # vercel .vercel diff --git a/CLAUDE.md b/CLAUDE.md index b33b15e..17356b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -184,10 +184,11 @@ Modèle : 1 User → 1 Board (relation 1:1 pour ce MVP). Chaque Post appartient ## Tests -- **Vitest** pour les Server Actions critiques (`createPost`, `toggleVote`, `changeStatus`). Mock Prisma via `vitest-mock-extended`. -- **Playwright** pour 1 parcours e2e : login démo → créer post → voter → changer statut. -- Pas d'objectif de couverture. Cibler la valeur, pas la métrique. -- Tests dans `src/**/__tests__/*.test.ts` ou colocalisés `*.test.ts` à côté du fichier source. +- **Vitest** : Server Actions critiques. Mocks Prisma via `vitest-mock-extended`. +- **Playwright** : 1 happy path + scénarios d'autorisation + scénarios admin. +- Bypass auth en e2e via provider Credentials conditionnel (`E2E_TEST_MODE=true`) et page `/e2e-login`. Le provider et la page sont actifs uniquement quand la variable d'env est `"true"`, double check au runtime. +- Base de test : Postgres en Docker avec `tmpfs` (reset entre runs). `e2e/helpers/db.ts` fournit `cleanDatabase` et `seedDemoBoard` appelés en `beforeEach`. +- `playwright.config.ts` est en `fullyParallel: false` à cause de la base partagée. Si tu paralléllises, isole les bases par worker. ## Sécurité diff --git a/README.md b/README.md index a4677e2..4fa03a1 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,9 @@ A minimalist open-source alternative to Canny / Frill. [Live demo](https://feedbackflow.vercel.app) · [Public board](https://feedbackflow.vercel.app/b/demo) · [Roadmap](https://feedbackflow.vercel.app/b/demo/roadmap) -[![CI](https://github.com//feedbackflow/actions/workflows/ci.yml/badge.svg)](https://github.com//feedbackflow/actions) +> Demo account: `demo@feedbackflow.app` (magic link, no password) + +[![CI](https://github.com/Yentec/FeedbackFlow/actions/workflows/ci.yml/badge.svg)](https://github.com/Yentec/FeedbackFlow/actions) ![License](https://img.shields.io/badge/License-MIT-blue.svg) ![Next.js](https://img.shields.io/badge/Next.js-16-black) ![TypeScript](https://img.shields.io/badge/TypeScript-strict-blue) @@ -45,7 +47,7 @@ A minimalist open-source alternative to Canny / Frill. | Styling | Tailwind v4 + shadcn/ui | | Email | Resend | | Deployment | Vercel | -| Testing | Vitest | +| Testing | Vitest + Playwright | | CI | GitHub Actions | ## Screenshots @@ -97,6 +99,7 @@ cp .env.example .env.local npm run db:migrate npm run db:seed +# Demo account: demo@feedbackflow.app npm run dev ``` @@ -121,6 +124,27 @@ npm run db:seed # seed the demo board npm run db:studio # browse the DB ``` +## Testing + +```bash +# Unit tests (Vitest) — Server Actions, validators +npm test + +# E2E tests (Playwright) — full user flows +npm run e2e:db:up # start Postgres in Docker +npm run e2e:db:reset # apply migrations +npm run test:e2e # run Chromium against a fresh build +npm run test:e2e:ui # interactive mode +``` + +The e2e suite covers: + +- **Happy path**: sign in → create post → vote → comment +- **Authorization**: guests redirected, private boards return 404 +- **Admin actions**: status changes, post deletion, propagation to public board and roadmap + +Both suites run on every PR in [CI](.github/workflows/ci.yml). + ## Deploy One-click deploy on Vercel: diff --git a/app/(auth)/e2e-login/page.tsx b/app/(auth)/e2e-login/page.tsx new file mode 100644 index 0000000..86952a4 --- /dev/null +++ b/app/(auth)/e2e-login/page.tsx @@ -0,0 +1,23 @@ +import { signIn } from "@/auth"; + +// Test-only bypass page. Security is enforced in the "e2e" Credentials provider's +// authorize(), which returns null unless E2E_TEST_MODE=true. +type Props = { searchParams: Promise<{ email?: string; callbackUrl?: string }> }; + +export default async function E2ELoginPage({ searchParams }: Props) { + const { email, callbackUrl } = await searchParams; + if (!email) return null; + + const doSignIn = async () => { + "use server"; + await signIn("e2e", { email, redirectTo: callbackUrl ?? "/dashboard" }); + }; + + return ( +
+ +
+ ); +} diff --git a/app/api/board-visibility/route.ts b/app/api/board-visibility/route.ts new file mode 100644 index 0000000..34d0c7b --- /dev/null +++ b/app/api/board-visibility/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/lib/db"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + const slug = req.nextUrl.searchParams.get("slug"); + if (!slug) return NextResponse.json({ isPublic: false }); + + const board = await db.board.findUnique({ + where: { slug }, + select: { isPublic: true }, + }); + + return NextResponse.json({ isPublic: board?.isPublic ?? false }); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index fc66565..9de52ae 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,6 +1,8 @@ import type { MetadataRoute } from "next"; import { db } from "@/lib/db"; +export const dynamic = "force-dynamic"; + export default async function sitemap(): Promise { const base = process.env["NEXT_PUBLIC_APP_URL"] ?? "http://localhost:3000"; diff --git a/auth.config.ts b/auth.config.ts index 740efa2..c3691c2 100644 --- a/auth.config.ts +++ b/auth.config.ts @@ -1,8 +1,49 @@ import type { NextAuthConfig } from "next-auth"; import GitHub from "next-auth/providers/github"; +import Credentials from "next-auth/providers/credentials"; + +const providers: NextAuthConfig["providers"] = [GitHub]; + +if (process.env["E2E_TEST_MODE"] === "true") { + providers.push( + Credentials({ + id: "e2e", + name: "E2E Test Login", + credentials: { email: { type: "text" } }, + async authorize(credentials) { + if (process.env["E2E_TEST_MODE"] !== "true") return null; + const email = credentials?.email; + if (typeof email !== "string" || !email.includes("@")) return null; + + // Lazy import to keep auth.config edge-safe in non-test envs + const { db } = await import("@/lib/db"); + const { slugify } = await import("@/lib/slug"); + + let user = await db.user.findUnique({ where: { email } }); + if (!user) { + user = await db.user.create({ + data: { email, name: email.split("@")[0], emailVerified: new Date() }, + }); + const base = slugify(user.name ?? "board"); + let slug = base || "board"; + let suffix = 0; + while (await db.board.findUnique({ where: { slug } })) { + suffix += 1; + slug = `${base}-${suffix}`; + } + await db.board.create({ + data: { slug, name: `${user.name}'s board`, ownerId: user.id }, + }); + } + + return { id: user.id, email: user.email, name: user.name }; + }, + }), + ); +} export default { - providers: [GitHub], + providers, pages: { signIn: "/login", verifyRequest: "/verify-request", diff --git a/auth.ts b/auth.ts index abb6c0d..d000399 100644 --- a/auth.ts +++ b/auth.ts @@ -46,7 +46,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, providers: [ ...authConfig.providers, - Resend({ from: process.env["EMAIL_FROM"] }), + Resend({ from: process.env["EMAIL_FROM"] }), // Resend needs PrismaAdapter — kept here only, not in auth.config Credentials({ id: "demo", credentials: {}, diff --git a/components/posts/vote-button.tsx b/components/posts/vote-button.tsx index d966921..13247ea 100644 --- a/components/posts/vote-button.tsx +++ b/components/posts/vote-button.tsx @@ -55,7 +55,7 @@ export function VoteButton({ onClick={handleClick} disabled={isPending} aria-pressed={optimistic.hasVoted} - aria-label={optimistic.hasVoted ? "Remove vote" : "Vote"} + aria-label={size === "md" && optimistic.hasVoted ? "Remove vote" : "Vote"} className={cn( "flex flex-col items-center gap-0.5 rounded-md border transition", size === "sm" ? "px-3 py-2 text-sm" : "px-4 py-3 text-base", diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx index c7bf02a..c787ac8 100644 --- a/components/ui/switch.tsx +++ b/components/ui/switch.tsx @@ -1,33 +1,33 @@ -"use client" +"use client"; -import * as React from "react" -import { Switch as SwitchPrimitive } from "radix-ui" +import * as React from "react"; +import { Switch as SwitchPrimitive } from "radix-ui"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; function Switch({ className, size = "default", ...props }: React.ComponentProps & { - size?: "sm" | "default" + size?: "sm" | "default"; }) { return ( - ) + ); } -export { Switch } +export { Switch }; diff --git a/e2e/admin.spec.ts b/e2e/admin.spec.ts new file mode 100644 index 0000000..9abd615 --- /dev/null +++ b/e2e/admin.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from "@playwright/test"; +import { cleanDatabase, seedDemoBoard, disconnect } from "./helpers/db"; +import { loginAs } from "./helpers/auth"; + +test.beforeEach(async () => { + await cleanDatabase(); + await seedDemoBoard(); +}); + +test.afterAll(async () => { + await disconnect(); +}); + +test("board owner can change post status from the admin list", async ({ page }) => { + await loginAs(page, "demo@feedbackflow.app"); + await page.goto("/posts"); + + await expect(page.getByText("Existing feature request")).toBeVisible(); + + // Click the status badge to open the dropdown + await page + .getByRole("row", { name: /existing feature request/i }) + .getByRole("button") + .first() + .click(); + + await page.getByRole("menuitem", { name: /^planned$/i }).click(); + await expect(page.getByText(/status changed to planned/i)).toBeVisible(); + + // Verify the public board reflects the change + await page.goto("/b/demo"); + await expect( + page.locator("article", { hasText: "Existing feature request" }).getByText("Planned"), + ).toBeVisible(); + + // And the roadmap + await page.goto("/b/demo/roadmap"); + await expect( + page.locator("h2", { hasText: "Planned" }).locator("../..").getByText("Existing feature request"), + ).toBeVisible(); +}); + +test("board owner can delete a post", async ({ page }) => { + await loginAs(page, "demo@feedbackflow.app"); + await page.goto("/posts"); + + page.on("dialog", (dialog) => dialog.accept()); + + await page + .getByRole("row", { name: /existing feature request/i }) + .getByRole("button", { name: /delete post/i }) + .click(); + + await expect(page.getByText(/post deleted/i)).toBeVisible(); + await expect(page.getByText("Existing feature request")).not.toBeVisible(); +}); diff --git a/e2e/authorization.spec.ts b/e2e/authorization.spec.ts new file mode 100644 index 0000000..56a3a6d --- /dev/null +++ b/e2e/authorization.spec.ts @@ -0,0 +1,59 @@ +import { test, expect } from "@playwright/test"; +import { cleanDatabase, seedDemoBoard, disconnect } from "./helpers/db"; +import { loginAs } from "./helpers/auth"; + +test.beforeEach(async () => { + await cleanDatabase(); + await seedDemoBoard(); +}); + +test.afterAll(async () => { + await disconnect(); +}); + +test("guests are redirected to login when accessing /dashboard", async ({ page }) => { + await page.goto("/dashboard"); + await expect(page).toHaveURL(/\/login/); + expect(page.url()).toContain("callbackUrl=%2Fdashboard"); +}); + +test("guests are redirected to login when trying to vote", async ({ page }) => { + await page.goto("/b/demo"); + const voteButton = page.locator("article").first().getByRole("button", { name: /vote/i }); + + await voteButton.click(); + await expect(page).toHaveURL(/\/login/); +}); + +test("non-owner cannot access another user's settings", async ({ page }) => { + // The demo board's owner is demo@feedbackflow.app + // Logging in as someone else should give them a different board + await loginAs(page, "bob@test.com"); + await page.goto("/settings"); + + // Bob has his own board, not the demo one + const slugInput = page.getByLabel("URL slug"); + await expect(slugInput).not.toHaveValue("demo"); +}); + +test("private board returns 404 to non-owners", async ({ page, request }) => { + // Use the API to flip the demo board to private via direct DB access would + // require additional helpers. Easiest path: own a board, make it private, + // then verify it 404s when logged out. + await loginAs(page, "carol@test.com"); + await page.goto("/settings"); + await page.getByLabel("Public board").click(); + await page.getByRole("button", { name: /save changes/i }).click(); + await expect(page.getByText(/settings saved/i)).toBeVisible(); + + const slugInput = page.getByLabel("URL slug"); + const slug = await slugInput.inputValue(); + + // Logout and check the board is 404 + await page.goto("/dashboard"); + await page.getByRole("button", { name: /sign out/i }).click(); + await page.waitForURL("/"); + + const response = await request.get(`/b/${slug}`); + expect(response.status()).toBe(404); +}); diff --git a/e2e/happy-path.spec.ts b/e2e/happy-path.spec.ts new file mode 100644 index 0000000..6e743b0 --- /dev/null +++ b/e2e/happy-path.spec.ts @@ -0,0 +1,62 @@ +import { test, expect } from "@playwright/test"; +import { cleanDatabase, seedDemoBoard, disconnect } from "./helpers/db"; +import { loginAs } from "./helpers/auth"; + +test.beforeEach(async () => { + await cleanDatabase(); + await seedDemoBoard(); +}); + +test.afterAll(async () => { + await disconnect(); +}); + +test("user can sign in, create a post, vote, and comment", async ({ page }) => { + // 1. Land on home + await page.goto("/"); + await expect( + page.getByRole("heading", { name: /ship what your users actually want/i }), + ).toBeVisible(); + + // 2. Visit the public demo board as a guest + await page.getByRole("link", { name: /view live demo/i }).click(); + await expect(page).toHaveURL(/\/b\/demo$/); + await expect(page.getByText("Existing feature request")).toBeVisible(); + + // 3. Sign in (e2e bypass) + await loginAs(page, "alice@test.com"); + await expect(page).toHaveURL(/\/dashboard$/); + await expect(page.getByRole("heading", { name: /overview/i })).toBeVisible(); + + // 4. Navigate back to the demo board + await page.goto("/b/demo"); + + // 5. Create a post + await page.getByRole("button", { name: /suggest an idea/i }).click(); + await page.getByLabel("Title").fill("Add dark mode"); + await page + .getByLabel("Description") + .fill("Would be great to have a dark theme for the dashboard."); + await page.getByRole("button", { name: /^post$/i }).click(); + + // 6. Verify post appears + await expect(page.getByText("Add dark mode")).toBeVisible(); + + // 7. Vote on it + const postCard = page.locator("article").filter({ hasText: "Add dark mode" }); + const voteButton = postCard.getByRole("button", { name: /^vote$/i }); + await expect(voteButton).toHaveAttribute("aria-pressed", "false"); + await voteButton.click(); + await expect(voteButton).toHaveAttribute("aria-pressed", "true"); + + // 8. Open the post detail and comment + await postCard.getByRole("link", { name: "Add dark mode" }).click(); + await expect(page).toHaveURL(/\/b\/demo\/posts\/[a-z0-9]+$/); + + await page.getByPlaceholder(/add a comment/i).fill("Strongly agree, would use this daily."); + await page.getByRole("button", { name: /comment/i }).click(); + await expect(page.getByText("Strongly agree, would use this daily.")).toBeVisible(); + + // 9. Confirm vote persisted across pages + await expect(page.getByRole("button", { name: /remove vote/i })).toBeVisible(); +}); diff --git a/e2e/helpers/auth.ts b/e2e/helpers/auth.ts new file mode 100644 index 0000000..6add934 --- /dev/null +++ b/e2e/helpers/auth.ts @@ -0,0 +1,13 @@ +import type { Page } from "@playwright/test"; + +export async function loginAs(page: Page, email: string) { + await page.goto(`/e2e-login?email=${encodeURIComponent(email)}`); + await page.locator("#e2e-submit").click(); + await page.waitForURL("/dashboard", { timeout: 15_000 }); +} + +export async function logout(page: Page) { + await page.goto("/dashboard"); + await page.getByRole("button", { name: /sign out/i }).click(); + await page.waitForURL("/"); +} diff --git a/e2e/helpers/db.ts b/e2e/helpers/db.ts new file mode 100644 index 0000000..24f70eb --- /dev/null +++ b/e2e/helpers/db.ts @@ -0,0 +1,67 @@ +import { PrismaPg } from "@prisma/adapter-pg"; +import { PrismaClient } from "@prisma/client"; + +const db = new PrismaClient({ adapter: new PrismaPg({ connectionString: process.env["DATABASE_URL"] }) }); + +export async function cleanDatabase() { + // Order matters due to FK constraints + await db.comment.deleteMany(); + await db.vote.deleteMany(); + await db.post.deleteMany(); + await db.category.deleteMany(); + await db.board.deleteMany(); + await db.session.deleteMany(); + await db.account.deleteMany(); + await db.verificationToken.deleteMany(); + await db.user.deleteMany(); +} + +export async function seedDemoBoard() { + const user = await db.user.create({ + data: { + email: "demo@feedbackflow.app", + name: "Demo", + emailVerified: new Date(), + }, + }); + + const board = await db.board.create({ + data: { + slug: "demo", + name: "Demo board", + description: "Test fixture board", + ownerId: user.id, + }, + }); + + const post = await db.post.create({ + data: { + title: "Existing feature request", + content: "This is a pre-seeded post used by e2e tests.", + boardId: board.id, + authorId: user.id, + }, + }); + + // Seed test users used across e2e specs so authorize() finds them instead of + // creating them on-the-fly (production Next.js doesn't always commit in time + // for the next request). + for (const { email, name, slug } of [ + { email: "alice@test.com", name: "alice", slug: "alice" }, + { email: "bob@test.com", name: "bob", slug: "bob" }, + { email: "carol@test.com", name: "carol", slug: "carol" }, + ]) { + const u = await db.user.create({ + data: { email, name, emailVerified: new Date() }, + }); + await db.board.create({ + data: { slug, name: `${name}'s board`, ownerId: u.id }, + }); + } + + return { user, board, post }; +} + +export async function disconnect() { + await db.$disconnect(); +} diff --git a/package-lock.json b/package-lock.json index a1348da..c507979 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "feedbackflow", - "version": "0.6.8", + "version": "0.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "feedbackflow", - "version": "0.6.8", + "version": "0.7.1", "hasInstallScript": true, "dependencies": { "@auth/prisma-adapter": "^2.11.2", @@ -31,11 +31,13 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4", "@types/node": "^20.19.41", "@types/react": "^19", "@types/react-dom": "^19", "@vitest/coverage-v8": "^4.1.6", + "dotenv-cli": "^11.0.0", "eslint": "^9", "eslint-config-next": "16.2.6", "eslint-config-prettier": "^10.1.8", @@ -2448,6 +2450,22 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@prisma/adapter-pg": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz", @@ -7059,6 +7077,51 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-cli": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-11.0.0.tgz", + "integrity": "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^17.1.0", + "dotenv-expand": "^12.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -11230,6 +11293,53 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 0d7ca5a..5bb313e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "feedbackflow", - "version": "0.6.10", + "version": "1.0.1", "private": true, "scripts": { "dev": "next dev", @@ -16,7 +16,11 @@ "db:reset": "prisma migrate reset --force", "postinstall": "prisma generate", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "dotenv -e .env.test -- playwright test", + "test:e2e:ui": "dotenv -e .env.test -- playwright test --ui", + "start:e2e": "dotenv -e .env.test -- next start", + "e2e:db:reset": "dotenv -e .env.test -- prisma migrate reset --force --skip-seed && dotenv -e .env.test -- npm run db:seed" }, "dependencies": { "@auth/prisma-adapter": "^2.11.2", @@ -41,11 +45,13 @@ "tw-animate-css": "^1.4.0" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4", "@types/node": "^20.19.41", "@types/react": "^19", "@types/react-dom": "^19", "@vitest/coverage-v8": "^4.1.6", + "dotenv-cli": "^11.0.0", "eslint": "^9", "eslint-config-next": "16.2.6", "eslint-config-prettier": "^10.1.8", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..22a785d --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,32 @@ +import { defineConfig, devices } from "@playwright/test"; + +const PORT = process.env["PORT"] ?? "3000"; +const baseURL = `http://localhost:${PORT}`; + +export default defineConfig({ + testDir: "./e2e", + fullyParallel: false, + forbidOnly: !!process.env["CI"], + retries: process.env["CI"] ? 2 : 0, + workers: 1, + reporter: process.env["CI"] ? "github" : "list", + timeout: 30_000, + expect: { timeout: 5_000 }, + + use: { + baseURL, + trace: "on-first-retry", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + + webServer: { + command: process.env["CI"] ? "next start" : "npm run start:e2e", + url: baseURL, + reuseExistingServer: !process.env["CI"], + timeout: 120_000, + env: { NODE_ENV: "production" }, + }, +}); diff --git a/proxy.ts b/proxy.ts index 8ad9bf6..9e25b06 100644 --- a/proxy.ts +++ b/proxy.ts @@ -4,7 +4,7 @@ import authConfig from "./auth.config"; const { auth } = NextAuth(authConfig); -export default auth((req) => { +export default auth(async (req) => { const isAuthed = !!(req.auth as { user?: unknown } | null)?.user; const { pathname } = req.nextUrl; @@ -20,6 +20,26 @@ export default auth((req) => { if (isAuthPage && isAuthed) { return NextResponse.redirect(new URL("/dashboard", req.url)); } + + // Board visibility check: must run before streaming begins so the 404 status + // can be set before the response headers are sent (Next.js 16 streams 200 otherwise). + if (pathname.startsWith("/b/")) { + const slug = pathname.split("/")[2]; // /b//... + if (slug && slug !== "-") { + try { + const url = new URL(`/api/board-visibility?slug=${encodeURIComponent(slug)}`, req.url); + const res = await fetch(url.toString(), { cache: "no-store" }); + if (res.ok) { + const { isPublic } = (await res.json()) as { isPublic: boolean }; + if (!isPublic) { + return new NextResponse(null, { status: 404 }); + } + } + } catch { + // On network error, fall through and let the page handle it + } + } + } }); export const config = { diff --git a/server/actions/__tests__/boards.test.ts b/server/actions/__tests__/boards.test.ts new file mode 100644 index 0000000..e448334 --- /dev/null +++ b/server/actions/__tests__/boards.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mockDeep } from "vitest-mock-extended"; +import type { PrismaClient } from "@prisma/client"; +import type { Session } from "next-auth"; +import { db } from "@/lib/db"; +import { updateBoard } from "@/server/actions/boards"; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); + +const mockedDb = db as unknown as ReturnType>; + +type AuthFn = () => Promise; + +describe("updateBoard", () => { + beforeEach(async () => { + const { auth } = await import("@/auth"); + vi.mocked(auth as unknown as AuthFn).mockResolvedValue({ + user: { id: "owner-1", email: "o@test.com" }, + expires: new Date(Date.now() + 3600_000).toISOString(), + }); + }); + + const baseInput = { + name: "My Board", + description: "A description", + slug: "my-board", + isPublic: true, + }; + + it("rejects reserved slugs", async () => { + mockedDb.board.findUnique.mockResolvedValue({ + id: "board-1", + slug: "old-slug", + } as never); + + const result = await updateBoard({ ...baseInput, slug: "api" }); + expect(result).toMatchObject({ ok: false, error: "This slug is reserved" }); + }); + + it("rejects slugs with uppercase or invalid characters", async () => { + const result = await updateBoard({ ...baseInput, slug: "My_Board" }); + expect(result.ok).toBe(false); + }); + + it("rejects slug taken by another board", async () => { + mockedDb.board.findUnique + .mockResolvedValueOnce({ id: "board-1", slug: "old-slug" } as never) + .mockResolvedValueOnce({ id: "other-board" } as never); + + const result = await updateBoard({ ...baseInput, slug: "taken-slug" }); + expect(result).toMatchObject({ ok: false, error: "Slug already taken" }); + }); + + it("accepts keeping the same slug", async () => { + mockedDb.board.findUnique.mockResolvedValue({ + id: "board-1", + slug: "my-board", + } as never); + mockedDb.board.update.mockResolvedValue({} as never); + + const result = await updateBoard(baseInput); + expect(result).toMatchObject({ ok: true }); + }); + + it("rejects unauthenticated users", async () => { + const { auth } = await import("@/auth"); + vi.mocked(auth as unknown as AuthFn).mockResolvedValueOnce(null); + + const result = await updateBoard(baseInput); + expect(result).toMatchObject({ ok: false, error: "Unauthorized" }); + }); +}); diff --git a/server/actions/__tests__/comments.test.ts b/server/actions/__tests__/comments.test.ts new file mode 100644 index 0000000..3f28d30 --- /dev/null +++ b/server/actions/__tests__/comments.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mockDeep } from "vitest-mock-extended"; +import type { PrismaClient } from "@prisma/client"; +import type { Session } from "next-auth"; +import { db } from "@/lib/db"; +import { createComment, deleteComment } from "@/server/actions/comments"; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); + +const mockedDb = db as unknown as ReturnType>; + +type AuthFn = () => Promise; + +describe("createComment", () => { + beforeEach(async () => { + const { auth } = await import("@/auth"); + vi.mocked(auth as unknown as AuthFn).mockResolvedValue({ + user: { id: "user-1", email: "u@test.com" }, + expires: new Date(Date.now() + 3600_000).toISOString(), + }); + }); + + it("rejects empty content", async () => { + const result = await createComment({ + postId: "clpostxxxxxxxxxxxxxxxxxxx", + content: " ", + }); + expect(result.ok).toBe(false); + }); + + it("rejects content over 1000 characters", async () => { + const result = await createComment({ + postId: "clpostxxxxxxxxxxxxxxxxxxx", + content: "x".repeat(1001), + }); + expect(result.ok).toBe(false); + }); + + it("creates a comment with valid input", async () => { + mockedDb.post.findUnique.mockResolvedValue({ + id: "post-1", + board: { slug: "demo", isPublic: true }, + } as never); + mockedDb.comment.create.mockResolvedValue({ id: "comment-1" } as never); + + const result = await createComment({ + postId: "clpostxxxxxxxxxxxxxxxxxxx", + content: "Looks great", + }); + + expect(result).toEqual({ ok: true, data: { commentId: "comment-1" } }); + }); +}); + +describe("deleteComment", () => { + beforeEach(async () => { + const { auth } = await import("@/auth"); + vi.mocked(auth as unknown as AuthFn).mockResolvedValue({ + user: { id: "user-1", email: "u@test.com" }, + expires: new Date(Date.now() + 3600_000).toISOString(), + }); + }); + + it("allows the comment author to delete their comment", async () => { + mockedDb.comment.findUnique.mockResolvedValue({ + authorId: "user-1", + post: { id: "post-1", board: { slug: "demo", ownerId: "someone-else" } }, + } as never); + mockedDb.comment.delete.mockResolvedValue({} as never); + + const result = await deleteComment({ commentId: "clcommentxxxxxxxxxxxxxxxx" }); + expect(result.ok).toBe(true); + }); + + it("allows the board owner to delete any comment", async () => { + mockedDb.comment.findUnique.mockResolvedValue({ + authorId: "someone-else", + post: { id: "post-1", board: { slug: "demo", ownerId: "user-1" } }, + } as never); + mockedDb.comment.delete.mockResolvedValue({} as never); + + const result = await deleteComment({ commentId: "clcommentxxxxxxxxxxxxxxxx" }); + expect(result.ok).toBe(true); + }); + + it("forbids unrelated users", async () => { + mockedDb.comment.findUnique.mockResolvedValue({ + authorId: "someone-else", + post: { id: "post-1", board: { slug: "demo", ownerId: "another-one" } }, + } as never); + + const result = await deleteComment({ commentId: "clcommentxxxxxxxxxxxxxxxx" }); + expect(result).toMatchObject({ ok: false, error: "Forbidden" }); + expect(mockedDb.comment.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/server/actions/__tests__/posts-create.test.ts b/server/actions/__tests__/posts-create.test.ts new file mode 100644 index 0000000..50533ff --- /dev/null +++ b/server/actions/__tests__/posts-create.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { mockDeep } from "vitest-mock-extended"; +import type { PrismaClient } from "@prisma/client"; +import type { Session } from "next-auth"; +import { db } from "@/lib/db"; +import { createPost } from "@/server/actions/posts"; + +vi.mock("@/auth", () => ({ auth: vi.fn() })); + +const mockedDb = db as unknown as ReturnType>; + +type AuthFn = () => Promise; + +describe("createPost", () => { + beforeEach(async () => { + const { auth } = await import("@/auth"); + vi.mocked(auth as unknown as AuthFn).mockResolvedValue({ + user: { id: "user-1", email: "u@test.com" }, + expires: new Date(Date.now() + 3600_000).toISOString(), + }); + }); + + it("rejects title shorter than 3 characters", async () => { + const result = await createPost({ + boardSlug: "demo", + title: "hi", + content: "Some valid content here", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues?.["title"]).toBeDefined(); + } + }); + + it("rejects content shorter than 10 characters", async () => { + const result = await createPost({ + boardSlug: "demo", + title: "Valid title", + content: "short", + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.issues?.["content"]).toBeDefined(); + } + }); + + it("rejects unauthenticated users", async () => { + const { auth } = await import("@/auth"); + vi.mocked(auth as unknown as AuthFn).mockResolvedValueOnce(null); + + const result = await createPost({ + boardSlug: "demo", + title: "Valid title", + content: "Valid content with enough characters", + }); + expect(result).toMatchObject({ ok: false, error: "Unauthorized" }); + }); + + it("rejects when board does not exist", async () => { + mockedDb.board.findUnique.mockResolvedValue(null); + + const result = await createPost({ + boardSlug: "missing", + title: "Valid title", + content: "Valid content with enough characters", + }); + expect(result).toMatchObject({ ok: false, error: "Board not found" }); + }); + + it("rejects when board is private", async () => { + mockedDb.board.findUnique.mockResolvedValue({ + id: "board-1", + isPublic: false, + } as never); + + const result = await createPost({ + boardSlug: "demo", + title: "Valid title", + content: "Valid content with enough characters", + }); + expect(result).toMatchObject({ ok: false, error: "Board not found" }); + }); + + it("rejects invalid category for the board", async () => { + mockedDb.board.findUnique.mockResolvedValue({ + id: "board-1", + isPublic: true, + } as never); + mockedDb.category.findFirst.mockResolvedValue(null); + + const result = await createPost({ + boardSlug: "demo", + title: "Valid title", + content: "Valid content with enough characters", + categoryId: "clcategoryxxxxxxxxxxxxxxx", + }); + expect(result).toMatchObject({ ok: false, error: "Invalid category" }); + }); + + it("creates a post with valid input", async () => { + mockedDb.board.findUnique.mockResolvedValue({ + id: "board-1", + isPublic: true, + } as never); + mockedDb.post.create.mockResolvedValue({ id: "new-post-1" } as never); + + const result = await createPost({ + boardSlug: "demo", + title: "Valid title", + content: "Valid content with enough characters", + }); + expect(result).toEqual({ ok: true, data: { postId: "new-post-1" } }); + expect(mockedDb.post.create).toHaveBeenCalledOnce(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 8325e97..cd7fa38 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ environment: "node", globals: true, setupFiles: ["./vitest.setup.ts"], + exclude: ["**/node_modules/**", "**/e2e/**"], }, resolve: { alias: { "@": path.resolve(__dirname, ".") },