commit 08a1054e3c98942c5d14f0ced6703f9f12024000 Author: bipproduction Date: Wed Apr 1 10:12:19 2026 +0800 Initial commit: full-stack Bun + Elysia + React template Elysia.js API with session-based auth (email/password + Google OAuth), role system (USER/ADMIN/SUPER_ADMIN), Prisma + PostgreSQL, React 19 with Mantine UI, TanStack Router, dark theme, and comprehensive test suite (unit, integration, E2E with Lightpanda). Co-Authored-By: Claude Opus 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..69b68c0 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# App +PORT=3000 +NODE_ENV=development + +# Dev Inspector +REACT_EDITOR=code diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78340dd --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# TanStack Router generated +src/frontend/routeTree.gen.ts + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +/generated/prisma diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4063a63 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,73 @@ +Default to using Bun instead of Node.js. + +- Use `bun ` instead of `node ` or `ts-node ` +- Use `bun test` instead of `jest` or `vitest` +- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install` +- Use `bun run + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..6db5402 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "bun-react-template", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "bun --watch src/serve.ts", + "build": "vite build", + "start": "NODE_ENV=production bun src/index.tsx", + "typecheck": "tsc --noEmit", + "test": "bun test", + "test:unit": "bun test tests/unit", + "test:integration": "bun test tests/integration", + "test:e2e": "bun test tests/e2e", + "lint": "biome check src/", + "lint:fix": "biome check --write src/", + "db:migrate": "bunx prisma migrate dev", + "db:seed": "bun run prisma/seed.ts", + "db:studio": "bunx prisma studio", + "db:generate": "bunx prisma generate", + "db:push": "bunx prisma db push" + }, + "dependencies": { + "@elysiajs/cors": "^1.4.1", + "@elysiajs/html": "^1.4.0", + "@mantine/core": "^8.3.18", + "@mantine/hooks": "^8.3.18", + "@prisma/client": "6", + "@tanstack/react-query": "^5.95.2", + "@tanstack/react-router": "^1.168.10", + "elysia": "^1.4.28", + "postcss": "^8.5.8", + "postcss-preset-mantine": "^1.18.0", + "postcss-simple-vars": "^7.0.1", + "react": "^19", + "react-dom": "^19", + "react-icons": "^5.6.0" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.10", + "@tanstack/router-vite-plugin": "^1.166.27", + "@types/bun": "latest", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^6.0.1", + "prisma": "6", + "puppeteer-core": "^24.40.0", + "typescript": "^6.0.2", + "vite": "^8.0.3" + } +} diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..5ada75c --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,14 @@ +export default { + plugins: { + 'postcss-preset-mantine': {}, + 'postcss-simple-vars': { + variables: { + 'mantine-breakpoint-xs': '36em', + 'mantine-breakpoint-sm': '48em', + 'mantine-breakpoint-md': '62em', + 'mantine-breakpoint-lg': '75em', + 'mantine-breakpoint-xl': '88em', + }, + }, + }, +} diff --git a/prisma/migrations/20260331061237_init/migration.sql b/prisma/migrations/20260331061237_init/migration.sql new file mode 100644 index 0000000..c968f52 --- /dev/null +++ b/prisma/migrations/20260331061237_init/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "user" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "user_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "session" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "session_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_email_key" ON "user"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "session_token_key" ON "session"("token"); + +-- CreateIndex +CREATE INDEX "session_token_idx" ON "session"("token"); + +-- AddForeignKey +ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260331084655_add_role/migration.sql b/prisma/migrations/20260331084655_add_role/migration.sql new file mode 100644 index 0000000..a2ab572 --- /dev/null +++ b/prisma/migrations/20260331084655_add_role/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN', 'SUPER_ADMIN'); + +-- AlterTable +ALTER TABLE "user" ADD COLUMN "role" "Role" NOT NULL DEFAULT 'USER'; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..e67948b --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,42 @@ +generator client { + provider = "prisma-client-js" + output = "../generated/prisma" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum Role { + USER + ADMIN + SUPER_ADMIN +} + +model User { + id String @id @default(uuid()) + name String + email String @unique + password String + role Role @default(USER) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + sessions Session[] + + @@map("user") +} + +model Session { + id String @id @default(uuid()) + token String @unique + userId String + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([token]) + @@map("session") +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..f90de89 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,39 @@ +import { PrismaClient } from '../generated/prisma' + +const prisma = new PrismaClient() + +const SUPER_ADMIN_EMAILS = (process.env.SUPER_ADMIN_EMAIL ?? '').split(',').map(e => e.trim()).filter(Boolean) + +async function main() { + const users = [ + { name: 'Super Admin', email: 'superadmin@example.com', password: 'superadmin123', role: 'SUPER_ADMIN' as const }, + { name: 'Admin', email: 'admin@example.com', password: 'admin123', role: 'ADMIN' as const }, + { name: 'User', email: 'user@example.com', password: 'user123', role: 'USER' as const }, + ] + + for (const u of users) { + const hashed = await Bun.password.hash(u.password, { algorithm: 'bcrypt' }) + await prisma.user.upsert({ + where: { email: u.email }, + update: { name: u.name, password: hashed, role: u.role }, + create: { name: u.name, email: u.email, password: hashed, role: u.role }, + }) + console.log(`Seeded: ${u.email} (${u.role})`) + } + + // Promote super admin emails from env + for (const email of SUPER_ADMIN_EMAILS) { + const user = await prisma.user.findUnique({ where: { email } }) + if (user && user.role !== 'SUPER_ADMIN') { + await prisma.user.update({ where: { email }, data: { role: 'SUPER_ADMIN' } }) + console.log(`Promoted to SUPER_ADMIN: ${email}`) + } + } +} + +main() + .catch((e) => { + console.error(e) + process.exit(1) + }) + .finally(() => prisma.$disconnect()) diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..899b74f --- /dev/null +++ b/src/app.ts @@ -0,0 +1,158 @@ +import { cors } from '@elysiajs/cors' +import { html } from '@elysiajs/html' +import { Elysia } from 'elysia' +import { prisma } from './lib/db' +import { env } from './lib/env' + +export function createApp() { + return new Elysia() + .use(cors()) + .use(html()) + + // ─── Global Error Handler ──────────────────────── + .onError(({ code, error }) => { + if (code === 'NOT_FOUND') { + return new Response(JSON.stringify({ error: 'Not Found', status: 404 }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }) + } + console.error('[Server Error]', error) + return new Response(JSON.stringify({ error: 'Internal Server Error', status: 500 }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + }) + + // API routes + .get('/health', () => ({ status: 'ok' })) + + // ─── Auth API ────────────────────────────────────── + .post('/api/auth/login', async ({ request, set }) => { + const { email, password } = (await request.json()) as { email: string; password: string } + let user = await prisma.user.findUnique({ where: { email } }) + if (!user || !(await Bun.password.verify(password, user.password))) { + set.status = 401 + return { error: 'Email atau password salah' } + } + // Auto-promote super admin from env + if (env.SUPER_ADMIN_EMAILS.includes(user.email) && user.role !== 'SUPER_ADMIN') { + user = await prisma.user.update({ where: { id: user.id }, data: { role: 'SUPER_ADMIN' } }) + } + const token = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours + await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) + set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` + return { user: { id: user.id, name: user.name, email: user.email, role: user.role } } + }) + + .post('/api/auth/logout', async ({ request, set }) => { + const cookie = request.headers.get('cookie') ?? '' + const token = cookie.match(/session=([^;]+)/)?.[1] + if (token) await prisma.session.deleteMany({ where: { token } }) + set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0' + return { ok: true } + }) + + .get('/api/auth/session', async ({ request, set }) => { + const cookie = request.headers.get('cookie') ?? '' + const token = cookie.match(/session=([^;]+)/)?.[1] + if (!token) { set.status = 401; return { user: null } } + const session = await prisma.session.findUnique({ + where: { token }, + include: { user: { select: { id: true, name: true, email: true, role: true } } }, + }) + if (!session || session.expiresAt < new Date()) { + if (session) await prisma.session.delete({ where: { id: session.id } }) + set.status = 401 + return { user: null } + } + return { user: session.user } + }) + + // ─── Google OAuth ────────────────────────────────── + .get('/api/auth/google', ({ request, set }) => { + const origin = new URL(request.url).origin + const params = new URLSearchParams({ + client_id: env.GOOGLE_CLIENT_ID, + redirect_uri: `${origin}/api/auth/callback/google`, + response_type: 'code', + scope: 'openid email profile', + access_type: 'offline', + prompt: 'consent', + }) + set.status = 302; set.headers['location'] =`https://accounts.google.com/o/oauth2/v2/auth?${params}` + }) + + .get('/api/auth/callback/google', async ({ request, set }) => { + const url = new URL(request.url) + const code = url.searchParams.get('code') + const origin = url.origin + + if (!code) { + set.status = 302; set.headers['location'] ='/login?error=google_failed' + return + } + + // Exchange code for tokens + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: env.GOOGLE_CLIENT_ID, + client_secret: env.GOOGLE_CLIENT_SECRET, + redirect_uri: `${origin}/api/auth/callback/google`, + grant_type: 'authorization_code', + }), + }) + + if (!tokenRes.ok) { + set.status = 302; set.headers['location'] ='/login?error=google_failed' + return + } + + const tokens = (await tokenRes.json()) as { access_token: string } + + // Get user info + const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${tokens.access_token}` }, + }) + + if (!userInfoRes.ok) { + set.status = 302; set.headers['location'] ='/login?error=google_failed' + return + } + + const googleUser = (await userInfoRes.json()) as { email: string; name: string } + + // Upsert user (no password for Google users) + const isSuperAdmin = env.SUPER_ADMIN_EMAILS.includes(googleUser.email) + const user = await prisma.user.upsert({ + where: { email: googleUser.email }, + update: { name: googleUser.name, ...(isSuperAdmin ? { role: 'SUPER_ADMIN' } : {}) }, + create: { email: googleUser.email, name: googleUser.name, password: '', role: isSuperAdmin ? 'SUPER_ADMIN' : 'USER' }, + }) + + // Create session + const token = crypto.randomUUID() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) + await prisma.session.create({ data: { token, userId: user.id, expiresAt } }) + + set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400` + set.status = 302; set.headers['location'] = user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' + }) + + // ─── Example API ─────────────────────────────────── + .get('/api/hello', () => ({ + message: 'Hello, world!', + method: 'GET', + })) + .put('/api/hello', () => ({ + message: 'Hello, world!', + method: 'PUT', + })) + .get('/api/hello/:name', ({ params }) => ({ + message: `Hello, ${params.name}!`, + })) +} diff --git a/src/frontend.tsx b/src/frontend.tsx new file mode 100644 index 0000000..88dfff1 --- /dev/null +++ b/src/frontend.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './frontend/App' + +// DevInspector hanya di-import saat dev (tree-shaken di production) +const InspectorWrapper = import.meta.env?.DEV + ? (await import('./frontend/DevInspector')).DevInspector + : ({ children }: { children: ReactNode }) => <>{children} + +// Remove splash screen after React mounts +function removeSplash() { + const splash = document.getElementById('splash') + if (splash) { + splash.classList.add('fade-out') + setTimeout(() => splash.remove(), 300) + } +} + +const elem = document.getElementById('root')! +const app = ( + + + +) + +// HMR-safe: reuse root agar React state preserved saat hot reload +if (import.meta.hot) { + import.meta.hot.data.root ??= createRoot(elem) + import.meta.hot.data.root.render(app) +} else { + createRoot(elem).render(app) +} + +removeSplash() diff --git a/src/frontend/App.tsx b/src/frontend/App.tsx new file mode 100644 index 0000000..6bf7800 --- /dev/null +++ b/src/frontend/App.tsx @@ -0,0 +1,40 @@ +import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core' +import '@mantine/core/styles.css' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRouter, RouterProvider } from '@tanstack/react-router' +import { routeTree } from './routeTree.gen' + +const theme = createTheme({ + primaryColor: 'blue', + fontFamily: 'Inter, system-ui, Avenir, Helvetica, Arial, sans-serif', +}) + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 30_000, retry: 1 }, + }, +}) + +const router = createRouter({ + routeTree, + context: { queryClient }, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +export function App() { + return ( + <> + + + + + + + + ) +} diff --git a/src/frontend/DevInspector.tsx b/src/frontend/DevInspector.tsx new file mode 100644 index 0000000..8b0ee32 --- /dev/null +++ b/src/frontend/DevInspector.tsx @@ -0,0 +1,157 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +interface CodeInfo { + relativePath: string + line: string + column: string +} + +/** Walk up DOM tree, cari elemen dengan data-inspector-* attributes */ +function findCodeInfo(target: HTMLElement): { element: HTMLElement; info: CodeInfo } | null { + let el: HTMLElement | null = target + while (el) { + const relativePath = el.getAttribute('data-inspector-relative-path') + const line = el.getAttribute('data-inspector-line') + const column = el.getAttribute('data-inspector-column') + if (relativePath && line) { + return { + element: el, + info: { relativePath, line, column: column ?? '1' }, + } + } + el = el.parentElement + } + return null +} + +function openInEditor(info: CodeInfo) { + fetch('/__open-in-editor', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + relativePath: info.relativePath, + lineNumber: info.line, + columnNumber: info.column, + }), + }) +} + +export function DevInspector({ children }: { children: React.ReactNode }) { + const [active, setActive] = useState(false) + const overlayRef = useRef(null) + const tooltipRef = useRef(null) + const lastInfoRef = useRef(null) + + const updateOverlay = useCallback((target: HTMLElement | null) => { + const ov = overlayRef.current + const tt = tooltipRef.current + if (!ov || !tt) return + + if (!target) { + ov.style.display = 'none' + tt.style.display = 'none' + lastInfoRef.current = null + return + } + + const result = findCodeInfo(target) + if (!result) { + ov.style.display = 'none' + tt.style.display = 'none' + lastInfoRef.current = null + return + } + + lastInfoRef.current = result.info + const rect = result.element.getBoundingClientRect() + ov.style.display = 'block' + ov.style.top = `${rect.top + window.scrollY}px` + ov.style.left = `${rect.left + window.scrollX}px` + ov.style.width = `${rect.width}px` + ov.style.height = `${rect.height}px` + + tt.style.display = 'block' + tt.textContent = `${result.info.relativePath}:${result.info.line}` + const ttTop = rect.top + window.scrollY - 24 + tt.style.top = `${ttTop > 0 ? ttTop : rect.bottom + window.scrollY + 4}px` + tt.style.left = `${rect.left + window.scrollX}px` + }, []) + + useEffect(() => { + if (!active) return + const onMouseOver = (e: MouseEvent) => updateOverlay(e.target as HTMLElement) + const onClick = (e: MouseEvent) => { + e.preventDefault() + e.stopPropagation() + const result = findCodeInfo(e.target as HTMLElement) + const info = result?.info ?? lastInfoRef.current + if (info) { + const loc = `${info.relativePath}:${info.line}:${info.column}` + navigator.clipboard.writeText(loc).catch(() => {}) + openInEditor(info) + } + setActive(false) + } + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setActive(false) + } + document.addEventListener('mouseover', onMouseOver, true) + document.addEventListener('click', onClick, true) + document.addEventListener('keydown', onKeyDown) + document.body.style.cursor = 'crosshair' + return () => { + document.removeEventListener('mouseover', onMouseOver, true) + document.removeEventListener('click', onClick, true) + document.removeEventListener('keydown', onKeyDown) + document.body.style.cursor = '' + if (overlayRef.current) overlayRef.current.style.display = 'none' + if (tooltipRef.current) tooltipRef.current.style.display = 'none' + } + }, [active, updateOverlay]) + + // Hotkey: Ctrl+Shift+Cmd+C (macOS) / Ctrl+Shift+Alt+C (Windows/Linux) + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key.toLowerCase() === 'c' && e.ctrlKey && e.shiftKey && (e.metaKey || e.altKey)) { + e.preventDefault() + setActive((prev) => !prev) + } + } + document.addEventListener('keydown', onKeyDown) + return () => document.removeEventListener('keydown', onKeyDown) + }, []) + + return ( + <> + {children} +
+
+ + ) +} diff --git a/src/frontend/components/ErrorPage.tsx b/src/frontend/components/ErrorPage.tsx new file mode 100644 index 0000000..6330f75 --- /dev/null +++ b/src/frontend/components/ErrorPage.tsx @@ -0,0 +1,26 @@ +import { Button, Center, Code, Stack, Text, Title } from '@mantine/core' +import { TbAlertTriangle, TbRefresh } from 'react-icons/tb' + +export function ErrorPage({ error }: { error: unknown }) { + const message = error instanceof Error ? error.message : 'Terjadi kesalahan yang tidak terduga' + + return ( +
+ + + 500 + Terjadi kesalahan pada server + + {message} + + + +
+ ) +} diff --git a/src/frontend/components/NotFound.tsx b/src/frontend/components/NotFound.tsx new file mode 100644 index 0000000..f95ccd8 --- /dev/null +++ b/src/frontend/components/NotFound.tsx @@ -0,0 +1,18 @@ +import { Button, Center, Stack, Text, Title } from '@mantine/core' +import { Link } from '@tanstack/react-router' +import { TbArrowLeft, TbError404 } from 'react-icons/tb' + +export function NotFound() { + return ( +
+ + + 404 + Halaman tidak ditemukan + + +
+ ) +} diff --git a/src/frontend/hooks/useAuth.ts b/src/frontend/hooks/useAuth.ts new file mode 100644 index 0000000..a394c69 --- /dev/null +++ b/src/frontend/hooks/useAuth.ts @@ -0,0 +1,66 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useNavigate } from '@tanstack/react-router' + +export type Role = 'USER' | 'ADMIN' | 'SUPER_ADMIN' + +export interface User { + id: string + name: string + email: string + role: Role +} + +async function apiFetch(path: string, init?: RequestInit): Promise { + const res = await fetch(path, { credentials: 'include', ...init }) + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Request failed' })) + throw new Error(err.error || `HTTP ${res.status}`) + } + return res.json() +} + +export function useSession() { + return useQuery({ + queryKey: ['auth', 'session'], + queryFn: () => apiFetch<{ user: User | null }>('/api/auth/session'), + retry: false, + staleTime: 30_000, + }) +} + +export function useLogin() { + const queryClient = useQueryClient() + const navigate = useNavigate() + + return useMutation({ + mutationFn: (data: { email: string; password: string }) => + apiFetch<{ user: User }>('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }), + onSuccess: (data) => { + queryClient.setQueryData(['auth', 'session'], data) + // Super admin → dashboard, others → profile + if (data.user.role === 'SUPER_ADMIN') { + navigate({ to: '/dashboard' }) + } else { + navigate({ to: '/profile' }) + } + }, + }) +} + +export function useLogout() { + const queryClient = useQueryClient() + const navigate = useNavigate() + + return useMutation({ + mutationFn: () => + apiFetch<{ ok: boolean }>('/api/auth/logout', { method: 'POST' }), + onSuccess: () => { + queryClient.setQueryData(['auth', 'session'], { user: null }) + navigate({ to: '/login' }) + }, + }) +} diff --git a/src/frontend/routes/__root.tsx b/src/frontend/routes/__root.tsx new file mode 100644 index 0000000..3f5ba15 --- /dev/null +++ b/src/frontend/routes/__root.tsx @@ -0,0 +1,14 @@ +import { Outlet, createRootRouteWithContext } from '@tanstack/react-router' +import type { QueryClient } from '@tanstack/react-query' +import { NotFound } from '@/frontend/components/NotFound' +import { ErrorPage } from '@/frontend/components/ErrorPage' + +interface RouterContext { + queryClient: QueryClient +} + +export const Route = createRootRouteWithContext()({ + component: () => , + notFoundComponent: NotFound, + errorComponent: ({ error }) => , +}) diff --git a/src/frontend/routes/dashboard.tsx b/src/frontend/routes/dashboard.tsx new file mode 100644 index 0000000..91bbb04 --- /dev/null +++ b/src/frontend/routes/dashboard.tsx @@ -0,0 +1,94 @@ +import { + Avatar, + Badge, + Button, + Card, + Container, + Group, + Paper, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Title, +} from '@mantine/core' +import { createFileRoute, redirect } from '@tanstack/react-router' +import { TbChartBar, TbLogout, TbSettings, TbUsers } from 'react-icons/tb' +import { useLogout, useSession } from '@/frontend/hooks/useAuth' + +export const Route = createFileRoute('/dashboard')({ + beforeLoad: async ({ context }) => { + try { + const data = await context.queryClient.ensureQueryData({ + queryKey: ['auth', 'session'], + queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), + }) + if (!data?.user) throw redirect({ to: '/login' }) + if (data.user.role !== 'SUPER_ADMIN') throw redirect({ to: '/profile' }) + } catch (e) { + if (e instanceof Error) throw redirect({ to: '/login' }) + throw e + } + }, + component: DashboardPage, +}) + +const stats = [ + { title: 'Users', value: '1,234', icon: TbUsers, color: 'blue' }, + { title: 'Revenue', value: '$12.4k', icon: TbChartBar, color: 'green' }, + { title: 'Settings', value: '3 active', icon: TbSettings, color: 'violet' }, +] + +function DashboardPage() { + const { data } = useSession() + const logout = useLogout() + const user = data?.user + + return ( + + + + Dashboard + + + + + + + {user?.name?.charAt(0).toUpperCase()} + +
+ + {user?.name} + SUPER ADMIN + + {user?.email} +
+
+
+ + + {stats.map((stat) => ( + + + {stat.title} + + + + + {stat.value} + + ))} + +
+
+ ) +} diff --git a/src/frontend/routes/index.tsx b/src/frontend/routes/index.tsx new file mode 100644 index 0000000..a325f74 --- /dev/null +++ b/src/frontend/routes/index.tsx @@ -0,0 +1,36 @@ +import { Button, Container, Group, Stack, Text, Title } from '@mantine/core' +import { Link, createFileRoute } from '@tanstack/react-router' +import { SiBun } from 'react-icons/si' +import { TbBrandReact, TbLogin, TbRocket } from 'react-icons/tb' + +export const Route = createFileRoute('/')({ + component: HomePage, +}) + +function HomePage() { + return ( + + + + + + + + Bun + Elysia + Vite + React + + + Full-stack starter template with Mantine UI, TanStack Router, and session-based auth. + + + + + + + + + ) +} diff --git a/src/frontend/routes/login.tsx b/src/frontend/routes/login.tsx new file mode 100644 index 0000000..b804e78 --- /dev/null +++ b/src/frontend/routes/login.tsx @@ -0,0 +1,115 @@ +import { + Alert, + Button, + Center, + Divider, + Paper, + PasswordInput, + Stack, + Text, + TextInput, + Title, +} from '@mantine/core' +import { createFileRoute, redirect } from '@tanstack/react-router' +import { useState } from 'react' +import { FcGoogle } from 'react-icons/fc' +import { TbAlertCircle, TbLogin, TbLock, TbMail } from 'react-icons/tb' +import { useLogin } from '@/frontend/hooks/useAuth' + +export const Route = createFileRoute('/login')({ + validateSearch: (search: Record) => ({ + error: (search.error as string) || undefined, + }), + beforeLoad: async ({ context }) => { + try { + const data = await context.queryClient.ensureQueryData({ + queryKey: ['auth', 'session'], + queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), + }) + if (data?.user) { + throw redirect({ to: data.user.role === 'SUPER_ADMIN' ? '/dashboard' : '/profile' }) + } + } catch (e) { + if (e instanceof Error) return + throw e + } + }, + component: LoginPage, +}) + +function LoginPage() { + const login = useLogin() + const { error: searchError } = Route.useSearch() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + login.mutate({ email, password }) + } + + return ( +
+ +
+ + + Login + + + + Demo: superadmin@example.com / superadmin123 +
+ atau: user@example.com / user123 +
+ + {(login.isError || searchError) && ( + } color="red" variant="light"> + {login.isError ? login.error.message : 'Login dengan Google gagal, coba lagi.'} + + )} + + } + value={email} + onChange={(e) => setEmail(e.currentTarget.value)} + required + /> + + } + value={password} + onChange={(e) => setPassword(e.currentTarget.value)} + required + /> + + + + + + +
+
+
+
+ ) +} diff --git a/src/frontend/routes/profile.tsx b/src/frontend/routes/profile.tsx new file mode 100644 index 0000000..e41b1d1 --- /dev/null +++ b/src/frontend/routes/profile.tsx @@ -0,0 +1,97 @@ +import { + Avatar, + Badge, + Button, + Container, + Group, + Paper, + Stack, + Text, + Title, +} from '@mantine/core' +import { createFileRoute, redirect } from '@tanstack/react-router' +import { TbLogout, TbUser } from 'react-icons/tb' +import { useLogout, useSession } from '@/frontend/hooks/useAuth' + +export const Route = createFileRoute('/profile')({ + beforeLoad: async ({ context }) => { + try { + const data = await context.queryClient.ensureQueryData({ + queryKey: ['auth', 'session'], + queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()), + }) + if (!data?.user) throw redirect({ to: '/login' }) + } catch (e) { + if (e instanceof Error) throw redirect({ to: '/login' }) + throw e + } + }, + component: ProfilePage, +}) + +const roleBadgeColor: Record = { + USER: 'blue', + ADMIN: 'violet', + SUPER_ADMIN: 'red', +} + +function ProfilePage() { + const { data } = useSession() + const logout = useLogout() + const user = data?.user + + return ( + + + + Profile + + + + + + + {user?.name?.charAt(0).toUpperCase()} + +
+ {user?.name} + {user?.email} +
+ + {user?.role} + +
+
+ + + + + + Account Info + + + Name + {user?.name} + + + Email + {user?.email} + + + Role + {user?.role} + + + +
+
+ ) +} diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..774eb83 --- /dev/null +++ b/src/index.css @@ -0,0 +1,187 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; +} +body { + margin: 0; + display: grid; + place-items: center; + min-width: 320px; + min-height: 100vh; + position: relative; +} +body::before { + content: ""; + position: fixed; + inset: 0; + z-index: -1; + opacity: 0.05; + background: url("./logo.svg"); + background-size: 256px; + transform: rotate(-12deg) scale(1.35); + animation: slide 30s linear infinite; + pointer-events: none; +} +@keyframes slide { + from { + background-position: 0 0; + } + to { + background-position: 256px 224px; + } +} +.app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; + position: relative; + z-index: 1; +} +.logo-container { + display: flex; + justify-content: center; + align-items: center; + gap: 2rem; + margin-bottom: 2rem; +} +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 0.3s; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.bun-logo { + transform: scale(1.2); +} +.bun-logo:hover { + filter: drop-shadow(0 0 2em #fbf0dfaa); +} +.react-logo { + animation: spin 20s linear infinite; +} +.react-logo:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} +@keyframes spin { + from { + transform: rotate(0); + } + to { + transform: rotate(360deg); + } +} +h1 { + font-size: 3.2em; + line-height: 1.1; +} +code { + background-color: #1a1a1a; + padding: 0.2em 0.4em; + border-radius: 0.3em; + font-family: monospace; +} +.api-tester { + margin: 2rem auto 0; + width: 100%; + max-width: 600px; + text-align: left; + display: flex; + flex-direction: column; + gap: 1rem; +} +.endpoint-row { + display: flex; + align-items: center; + gap: 0.5rem; + background: #1a1a1a; + padding: 0.75rem; + border-radius: 12px; + font: monospace; + border: 2px solid #fbf0df; + transition: 0.3s; + width: 100%; + box-sizing: border-box; +} +.endpoint-row:focus-within { + border-color: #f3d5a3; +} +.method { + background: #fbf0df; + color: #1a1a1a; + padding: 0.3rem 0.7rem; + border-radius: 8px; + font-weight: 700; + font-size: 0.9em; + appearance: none; + margin: 0; + width: min-content; + display: block; + flex-shrink: 0; + border: none; +} +.method option { + text-align: left; +} +.url-input { + width: 100%; + flex: 1; + background: 0; + border: 0; + color: #fbf0df; + font: 1em monospace; + padding: 0.2rem; + outline: 0; +} +.url-input:focus { + color: #fff; +} +.url-input::placeholder { + color: rgba(251, 240, 223, 0.4); +} +.send-button { + background: #fbf0df; + color: #1a1a1a; + border: 0; + padding: 0.4rem 1.2rem; + border-radius: 8px; + font-weight: 700; + transition: 0.1s; + cursor: var(--bun-cursor); +} +.send-button:hover { + background: #f3d5a3; + transform: translateY(-1px); + cursor: pointer; +} +.response-area { + width: 100%; + min-height: 120px; + background: #1a1a1a; + border: 2px solid #fbf0df; + border-radius: 12px; + padding: 0.75rem; + color: #fbf0df; + font: monospace; + resize: vertical; + box-sizing: border-box; +} +.response-area:focus { + border-color: #f3d5a3; +} +.response-area::placeholder { + color: rgba(251, 240, 223, 0.4); +} +@media (prefers-reduced-motion) { + *, + ::before, + ::after { + animation: none !important; + } +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..8d6ab81 --- /dev/null +++ b/src/index.tsx @@ -0,0 +1,177 @@ +/// + +import fs from 'node:fs' +import path from 'node:path' +import { env } from './lib/env' + +const isProduction = env.NODE_ENV === 'production' + +// ─── Route Classification ────────────────────────────── +const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health'] + +function isApiRoute(pathname: string): boolean { + return API_PREFIXES.some((p) => pathname.startsWith(p)) || pathname === '/health' +} + +// ─── Vite Dev Server (dev only) ──────────────────────── +let vite: Awaited> | null = null +if (!isProduction) { + const { createVite } = await import('./vite') + vite = await createVite() +} + +// ─── Frontend Serving ────────────────────────────────── +async function serveFrontend(request: Request): Promise { + const url = new URL(request.url) + const pathname = url.pathname + + if (!isProduction && vite) { + // === DEVELOPMENT: Vite Middleware Mode === + + // SPA route → serve index.html via Vite transform + if ( + pathname === '/' || + (!pathname.includes('.') && + !pathname.startsWith('/@') && + !pathname.startsWith('/__open-stack-frame-in-editor')) + ) { + const htmlPath = path.resolve('index.html') + let htmlContent = fs.readFileSync(htmlPath, 'utf-8') + htmlContent = await vite.transformIndexHtml(pathname, htmlContent) + + // Dedupe: Vite 8 middlewareMode injects react-refresh preamble twice + const preamble = + '' + const firstIdx = htmlContent.indexOf(preamble) + if (firstIdx !== -1) { + const secondIdx = htmlContent.indexOf(preamble, firstIdx + preamble.length) + if (secondIdx !== -1) { + htmlContent = htmlContent.slice(0, secondIdx) + htmlContent.slice(secondIdx + preamble.length) + } + } + + return new Response(htmlContent, { + headers: { 'Content-Type': 'text/html' }, + }) + } + + // Asset/module requests → proxy ke Vite middleware + // Bridge: Bun Request → Node.js IncomingMessage/ServerResponse + return new Promise((resolve) => { + const req = new Proxy(request, { + get(target, prop) { + if (prop === 'url') return pathname + url.search + if (prop === 'method') return request.method + if (prop === 'headers') return Object.fromEntries(request.headers as any) + return (target as any)[prop] + }, + }) as any + + const chunks: (Buffer | Uint8Array)[] = [] + const res = { + statusCode: 200, + headers: {} as Record, + setHeader(name: string, value: string) { this.headers[name.toLowerCase()] = value; return this }, + getHeader(name: string) { return this.headers[name.toLowerCase()] }, + removeHeader(name: string) { delete this.headers[name.toLowerCase()] }, + writeHead(code: number, reasonOrHeaders?: string | Record, maybeHeaders?: Record) { + this.statusCode = code + const hdrs = typeof reasonOrHeaders === 'object' ? reasonOrHeaders : maybeHeaders + if (hdrs) for (const [k, v] of Object.entries(hdrs)) this.headers[k.toLowerCase()] = String(v) + return this + }, + write(chunk: any) { + if (chunk) chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) + return true + }, + end(data?: any) { + if (data) { + if (typeof data === 'string') chunks.push(Buffer.from(data)) + else if (data instanceof Uint8Array || Buffer.isBuffer(data)) chunks.push(data) + } + resolve(new Response( + chunks.length > 0 ? Buffer.concat(chunks) : null, + { status: this.statusCode, headers: this.headers }, + )) + }, + once() { return this }, + on() { return this }, + emit() { return this }, + removeListener() { return this }, + } as any + + vite.middlewares(req, res, (err: any) => { + if (err) { + resolve(new Response(err.stack || err.toString(), { status: 500 })) + return + } + resolve(new Response('Not Found', { status: 404 })) + }) + }) + } + + // === PRODUCTION: Static Files + SPA Fallback === + const filePath = path.join('dist', pathname === '/' ? 'index.html' : pathname) + + if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { + const ext = path.extname(filePath) + const contentType: Record = { + '.js': 'application/javascript', '.css': 'text/css', + '.html': 'text/html; charset=utf-8', '.json': 'application/json', + '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', + } + const isHashed = pathname.startsWith('/assets/') + return new Response(Bun.file(filePath), { + headers: { + 'Content-Type': contentType[ext] ?? 'application/octet-stream', + 'Cache-Control': isHashed ? 'public, max-age=31536000, immutable' : 'public, max-age=3600', + }, + }) + } + + // SPA fallback — semua route yang tidak match file → index.html + const indexHtml = path.join('dist', 'index.html') + if (fs.existsSync(indexHtml)) { + return new Response(Bun.file(indexHtml), { + headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' }, + }) + } + + return new Response('Not Found', { status: 404 }) +} + +// ─── Elysia App ──────────────────────────────────────── +import { createApp } from './app' + +const app = createApp() + + // Frontend intercept — onRequest jalan SEBELUM route matching + .onRequest(async ({ request }) => { + const pathname = new URL(request.url).pathname + + // Dev inspector: open file di editor + if (!isProduction && pathname === '/__open-in-editor' && request.method === 'POST') { + const { relativePath, lineNumber, columnNumber } = (await request.json()) as { + relativePath: string; lineNumber: string; columnNumber: string + } + const file = `${process.cwd()}/${relativePath}` + const editor = env.REACT_EDITOR + const loc = `${file}:${lineNumber}:${columnNumber}` + // zed & subl: editor file:line:col — code & cursor: editor --goto file:line:col + const noGotoEditors = ['subl', 'zed'] + const args = noGotoEditors.includes(editor) ? [loc] : ['--goto', loc] + const editorPath = Bun.which(editor) + if (editorPath) Bun.spawn([editor, ...args], { stdio: ['ignore', 'ignore', 'ignore'] }) + return new Response('ok') + } + + // Non-API route → serve frontend + if (!isApiRoute(pathname)) { + return serveFrontend(request) + } + // undefined → lanjut ke Elysia route matching + }) + + .listen(env.PORT) + +console.log(`Server running at http://localhost:${app.server!.port}`) diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..dcc65f6 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,13 @@ +import { PrismaClient } from '../../generated/prisma' + +const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } + +export const prisma = + globalForPrisma.prisma ?? + new PrismaClient({ + log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'], + }) + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma +} diff --git a/src/lib/env.ts b/src/lib/env.ts new file mode 100644 index 0000000..98597c8 --- /dev/null +++ b/src/lib/env.ts @@ -0,0 +1,19 @@ +function optional(key: string, fallback: string): string { + return process.env[key] ?? fallback +} + +function required(key: string): string { + const value = process.env[key] + if (!value) throw new Error(`Missing required environment variable: ${key}`) + return value +} + +export const env = { + PORT: parseInt(optional('PORT', '3000'), 10), + NODE_ENV: optional('NODE_ENV', 'development'), + REACT_EDITOR: optional('REACT_EDITOR', 'code'), + DATABASE_URL: required('DATABASE_URL'), + GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'), + GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'), + SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean), +} as const diff --git a/src/logo.svg b/src/logo.svg new file mode 100644 index 0000000..7ef1500 --- /dev/null +++ b/src/logo.svg @@ -0,0 +1 @@ +Bun Logo \ No newline at end of file diff --git a/src/react.svg b/src/react.svg new file mode 100644 index 0000000..1ab815a --- /dev/null +++ b/src/react.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/serve.ts b/src/serve.ts new file mode 100644 index 0000000..3d0d7d6 --- /dev/null +++ b/src/serve.ts @@ -0,0 +1,3 @@ +// Workaround: Bun 1.3.6 EADDRINUSE errno:0 +// Dynamic import memberi waktu OS release port sebelum binding +import('./index.tsx') diff --git a/src/vite.ts b/src/vite.ts new file mode 100644 index 0000000..7c7e429 --- /dev/null +++ b/src/vite.ts @@ -0,0 +1,180 @@ +import fs from 'node:fs' +import path from 'node:path' +import { TanStackRouterVite } from '@tanstack/router-vite-plugin' +import react from '@vitejs/plugin-react' +import type { Plugin } from 'vite' +import { createServer as createViteServer } from 'vite' + +/** + * Custom Vite plugin: inject data-inspector-* attributes ke JSX via regex. + * enforce: "pre" → jalan SEBELUM OXC transform JSX. + * + * Karena plugin lain (OXC, TanStack) bisa mengubah source sebelum kita + * (collapse lines, resolve imports), kita baca file ASLI dari disk untuk + * line number yang akurat, lalu cross-reference dengan code yang diterima. + */ +function inspectorPlugin(): Plugin { + const rootDir = process.cwd() + return { + name: 'inspector-inject', + enforce: 'pre', + transform(code, id) { + if (!/\.[jt]sx(\?|$)/.test(id) || id.includes('node_modules')) return null + if (!code.includes('<')) return null + + const cleanId = id.replace(/\?.*$/, '') + const relativePath = path.relative(rootDir, cleanId) + + // Baca file asli dari disk untuk line number akurat + let originalLines: string[] | null = null + try { + originalLines = fs.readFileSync(cleanId, 'utf-8').split('\n') + } catch {} + + let modified = false + let lastOrigIdx = 0 + + const lines = code.split('\n') + const result: string[] = [] + + for (let i = 0; i < lines.length; i++) { + let line = lines[i] + const jsxPattern = /(<(?:[A-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)*|[a-z][a-zA-Z0-9-]*(?:\.[a-zA-Z][a-zA-Z0-9]*)*))\b/g + let match: RegExpExecArray | null = null + + while ((match = jsxPattern.exec(line)) !== null) { + const charBefore = match.index > 0 ? line[match.index - 1] : '' + if (/[a-zA-Z0-9_$.]/.test(charBefore)) continue + + // Cari line number asli di file original + let actualLine = i + 1 + if (originalLines) { + const afterTag = line.slice(match.index) + // Snippet: tag + atribut sampai '>' pertama, tanpa injected attrs + const snippet = afterTag.split('>')[0] + .replace(/\s*data-inspector-[^"]*"[^"]*"/g, '') + .trim() + // Tag name saja sebagai fallback (e.g. " yang di-collapse jadi 1 baris + if (!found) { + for (let j = lastOrigIdx; j < originalLines.length; j++) { + if (originalLines[j].includes(tagName)) { + actualLine = j + 1 + lastOrigIdx = j + 1 + found = true + break + } + } + } + + // 3) Last resort: search dari awal dengan full snippet + if (!found) { + for (let j = 0; j < originalLines.length; j++) { + if (originalLines[j].includes(snippet)) { + actualLine = j + 1 + lastOrigIdx = j + 1 + found = true + break + } + } + } + + // 4) Last resort: search dari awal dengan tag name + if (!found) { + for (let j = 0; j < originalLines.length; j++) { + if (originalLines[j].includes(tagName) && !originalLines[j].trim().startsWith(' { + test('GET /api/hello returns message', async () => { + const { page, cleanup } = await createPage() + try { + const body = await page.getResponseBody('/api/hello') + const data = JSON.parse(body) + expect(data).toEqual({ message: 'Hello, world!', method: 'GET' }) + } finally { + cleanup() + } + }) + + test('GET /api/hello/:name returns personalized message', async () => { + const { page, cleanup } = await createPage() + try { + const body = await page.getResponseBody('/api/hello/Bun') + const data = JSON.parse(body) + expect(data).toEqual({ message: 'Hello, Bun!' }) + } finally { + cleanup() + } + }) +}) diff --git a/tests/e2e/auth-api.test.ts b/tests/e2e/auth-api.test.ts new file mode 100644 index 0000000..080b13d --- /dev/null +++ b/tests/e2e/auth-api.test.ts @@ -0,0 +1,35 @@ +import { test, expect, describe } from 'bun:test' +import { createPage, APP_HOST } from './browser' + +describe('E2E: Auth API via browser', () => { + test('GET /api/auth/session page shows response', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST + '/api/auth/session') + // Navigating to a JSON API endpoint — body contains the JSON text + const bodyText = await page.evaluate('document.body.innerText || document.body.textContent || ""') + // Should contain "user" key in the response (either null or valid user) + // If empty, that's also acceptable (401 may not render body in Lightpanda) + if (bodyText.length > 0) { + const data = JSON.parse(bodyText) + expect(data.user).toBeNull() + } else { + // 401 response — Lightpanda may not render the body + expect(bodyText).toBe('') + } + } finally { + cleanup() + } + }) + + test('GET /api/auth/google redirects to Google OAuth', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST + '/api/auth/google') + const url = await page.url() + expect(url).toContain('accounts.google.com') + } finally { + cleanup() + } + }) +}) diff --git a/tests/e2e/browser.ts b/tests/e2e/browser.ts new file mode 100644 index 0000000..cc3c229 --- /dev/null +++ b/tests/e2e/browser.ts @@ -0,0 +1,162 @@ +/** + * Lightpanda browser helper for E2E tests. + * Lightpanda runs in Docker, so localhost is accessed via host.docker.internal. + */ + +const WS_ENDPOINT = process.env.LIGHTPANDA_WS ?? 'ws://127.0.0.1:9222' +const APP_HOST = process.env.E2E_APP_HOST ?? 'http://host.docker.internal:3000' + +export { APP_HOST } + +interface CDPResponse { + id?: number + method?: string + params?: Record + result?: Record + error?: { code: number; message: string } + sessionId?: string +} + +export class LightpandaPage { + private ws: WebSocket + private sessionId: string + private idCounter = 1 + private pending = new Map void; reject: (e: Error) => void }>() + private ready: Promise + + constructor(ws: WebSocket, sessionId: string) { + this.ws = ws + this.sessionId = sessionId + + this.ws.addEventListener('message', (e) => { + const data: CDPResponse = JSON.parse(e.data as string) + if (data.id && this.pending.has(data.id)) { + const p = this.pending.get(data.id)! + this.pending.delete(data.id) + if (data.error) p.reject(new Error(data.error.message)) + else p.resolve(data.result) + } + }) + + // Enable page events + this.ready = this.send('Page.enable').then(() => {}) + } + + private send(method: string, params: Record = {}): Promise { + return new Promise((resolve, reject) => { + const id = this.idCounter++ + this.pending.set(id, { resolve, reject }) + this.ws.send(JSON.stringify({ id, method, params, sessionId: this.sessionId })) + setTimeout(() => { + if (this.pending.has(id)) { + this.pending.delete(id) + reject(new Error(`CDP timeout: ${method}`)) + } + }, 15000) + }) + } + + async goto(path: string): Promise { + const url = path.startsWith('http') ? path : `${APP_HOST}${path}` + await this.ready + const result = await this.send('Page.navigate', { url }) + if (result?.errorText) throw new Error(`Navigation failed: ${result.errorText}`) + // Wait for load + await new Promise(r => setTimeout(r, 1500)) + } + + async evaluate(expression: string): Promise { + const result = await this.send('Runtime.evaluate', { + expression, + returnByValue: true, + awaitPromise: true, + }) + if (result?.exceptionDetails) { + throw new Error(`Evaluate error: ${result.exceptionDetails.text || JSON.stringify(result.exceptionDetails)}`) + } + return result?.result?.value as T + } + + async title(): Promise { + return this.evaluate('document.title') + } + + async textContent(selector: string): Promise { + return this.evaluate(`document.querySelector('${selector}')?.textContent ?? null`) + } + + async getAttribute(selector: string, attr: string): Promise { + return this.evaluate(`document.querySelector('${selector}')?.getAttribute('${attr}') ?? null`) + } + + async querySelectorAll(selector: string, property = 'textContent'): Promise { + return this.evaluate(`Array.from(document.querySelectorAll('${selector}')).map(el => el.${property})`) + } + + async url(): Promise { + return this.evaluate('window.location.href') + } + + async getResponseBody(path: string): Promise { + const url = path.startsWith('http') ? path : `${APP_HOST}${path}` + await this.goto(url) + return this.evaluate('document.body.innerText') + } + + async setCookie(name: string, value: string): Promise { + await this.send('Network.setCookie', { + name, + value, + domain: new URL(APP_HOST).hostname, + path: '/', + }) + } +} + +export async function createPage(): Promise<{ page: LightpandaPage; cleanup: () => void }> { + const ws = new WebSocket(WS_ENDPOINT) + + await new Promise((resolve, reject) => { + ws.onopen = () => resolve() + ws.onerror = () => reject(new Error(`Cannot connect to Lightpanda at ${WS_ENDPOINT}`)) + }) + + // Create target + const targetId = await new Promise((resolve, reject) => { + const id = 1 + ws.send(JSON.stringify({ id, method: 'Target.createTarget', params: { url: 'about:blank' } })) + const handler = (e: MessageEvent) => { + const data: CDPResponse = JSON.parse(e.data as string) + if (data.id === id) { + ws.removeEventListener('message', handler) + if (data.error) reject(new Error(data.error.message)) + else resolve(data.result!.targetId) + } + } + ws.addEventListener('message', handler) + }) + + // Attach to target + const sessionId = await new Promise((resolve, reject) => { + const id = 2 + ws.send(JSON.stringify({ id, method: 'Target.attachToTarget', params: { targetId, flatten: true } })) + const handler = (e: MessageEvent) => { + const data: CDPResponse = JSON.parse(e.data as string) + if (data.id === id) { + ws.removeEventListener('message', handler) + if (data.error) reject(new Error(data.error.message)) + else resolve(data.result!.sessionId) + } + } + ws.addEventListener('message', handler) + }) + + const page = new LightpandaPage(ws, sessionId) + + return { + page, + cleanup: () => { + try { ws.close() } catch {} + }, + } +} diff --git a/tests/e2e/google-oauth.test.ts b/tests/e2e/google-oauth.test.ts new file mode 100644 index 0000000..b9b2ae7 --- /dev/null +++ b/tests/e2e/google-oauth.test.ts @@ -0,0 +1,27 @@ +import { test, expect, describe } from 'bun:test' +import { createPage, APP_HOST } from './browser' + +describe('E2E: Google OAuth redirect', () => { + test('navigating to /api/auth/google ends up at Google', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST + '/api/auth/google') + const url = await page.url() + expect(url).toContain('accounts.google.com') + } finally { + cleanup() + } + }) + + test('callback without code redirects to login error', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST + '/api/auth/callback/google') + const url = await page.url() + expect(url).toContain('/login') + expect(url).toContain('error=google_failed') + } finally { + cleanup() + } + }) +}) diff --git a/tests/e2e/health.test.ts b/tests/e2e/health.test.ts new file mode 100644 index 0000000..bf9a5b7 --- /dev/null +++ b/tests/e2e/health.test.ts @@ -0,0 +1,15 @@ +import { test, expect, describe, afterAll } from 'bun:test' +import { createPage } from './browser' + +describe('E2E: Health endpoint', () => { + test('returns status ok', async () => { + const { page, cleanup } = await createPage() + try { + const body = await page.getResponseBody('/health') + const data = JSON.parse(body) + expect(data).toEqual({ status: 'ok' }) + } finally { + cleanup() + } + }) +}) diff --git a/tests/e2e/landing-page.test.ts b/tests/e2e/landing-page.test.ts new file mode 100644 index 0000000..04d89c1 --- /dev/null +++ b/tests/e2e/landing-page.test.ts @@ -0,0 +1,49 @@ +import { test, expect, describe } from 'bun:test' +import { createPage, APP_HOST } from './browser' + +describe('E2E: Landing page', () => { + test('serves HTML with correct title', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST) + const title = await page.title() + expect(title).toBe('My App') + } finally { + cleanup() + } + }) + + test('has dark color-scheme meta tag', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST) + const meta = await page.evaluate('document.querySelector("meta[name=color-scheme]").content') + expect(meta).toBe('dark') + } finally { + cleanup() + } + }) + + test('splash screen removed after JS execution', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST) + // Lightpanda executes JS, so splash should be gone + const splashExists = await page.evaluate('document.getElementById("splash") !== null') + expect(splashExists).toBe(false) + } finally { + cleanup() + } + }) + + test('root div present', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST) + const root = await page.evaluate('document.getElementById("root") !== null') + expect(root).toBe(true) + } finally { + cleanup() + } + }) +}) diff --git a/tests/e2e/login-page.test.ts b/tests/e2e/login-page.test.ts new file mode 100644 index 0000000..9dfdb14 --- /dev/null +++ b/tests/e2e/login-page.test.ts @@ -0,0 +1,59 @@ +import { test, expect, describe } from 'bun:test' +import { createPage, APP_HOST } from './browser' + +describe('E2E: Login page', () => { + test('serves HTML with correct title', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST + '/login') + const title = await page.title() + expect(title).toBe('My App') + } finally { + cleanup() + } + }) + + test('has dark theme set on html element', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST + '/login') + const colorScheme = await page.evaluate('document.documentElement.getAttribute("data-mantine-color-scheme")') + expect(colorScheme).toBe('dark') + } finally { + cleanup() + } + }) + + test('has dark background meta', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST + '/login') + const meta = await page.evaluate('document.querySelector("meta[name=color-scheme]").content') + expect(meta).toBe('dark') + } finally { + cleanup() + } + }) + + test('root element exists for React mount', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST + '/login') + const root = await page.evaluate('document.getElementById("root") !== null') + expect(root).toBe(true) + } finally { + cleanup() + } + }) + + test('splash removed after app load', async () => { + const { page, cleanup } = await createPage() + try { + await page.goto(APP_HOST + '/login') + const splashGone = await page.evaluate('document.getElementById("splash") === null') + expect(splashGone).toBe(true) + } finally { + cleanup() + } + }) +}) diff --git a/tests/helpers.ts b/tests/helpers.ts new file mode 100644 index 0000000..ab03451 --- /dev/null +++ b/tests/helpers.ts @@ -0,0 +1,38 @@ +import { prisma } from '../src/lib/db' +import { createApp } from '../src/app' + +export { prisma } + +export function createTestApp() { + const app = createApp() + return app +} + +/** Create a test user with hashed password, returns the user record */ +export async function seedTestUser(email = 'test@example.com', password = 'test123', name = 'Test User', role: 'USER' | 'ADMIN' | 'SUPER_ADMIN' = 'USER') { + const hashed = await Bun.password.hash(password, { algorithm: 'bcrypt' }) + return prisma.user.upsert({ + where: { email }, + update: { name, password: hashed, role }, + create: { email, name, password: hashed, role }, + }) +} + +/** Create a session for a user, returns the token */ +export async function createTestSession(userId: string, expiresAt?: Date) { + const token = crypto.randomUUID() + await prisma.session.create({ + data: { + token, + userId, + expiresAt: expiresAt ?? new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }) + return token +} + +/** Clean up test data */ +export async function cleanupTestData() { + await prisma.session.deleteMany() + await prisma.user.deleteMany() +} diff --git a/tests/integration/api-hello.test.ts b/tests/integration/api-hello.test.ts new file mode 100644 index 0000000..23d103b --- /dev/null +++ b/tests/integration/api-hello.test.ts @@ -0,0 +1,27 @@ +import { test, expect, describe } from 'bun:test' +import { createTestApp } from '../helpers' + +const app = createTestApp() + +describe('Example API routes', () => { + test('GET /api/hello returns message', async () => { + const res = await app.handle(new Request('http://localhost/api/hello')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ message: 'Hello, world!', method: 'GET' }) + }) + + test('PUT /api/hello returns message', async () => { + const res = await app.handle(new Request('http://localhost/api/hello', { method: 'PUT' })) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ message: 'Hello, world!', method: 'PUT' }) + }) + + test('GET /api/hello/:name returns personalized message', async () => { + const res = await app.handle(new Request('http://localhost/api/hello/Bun')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ message: 'Hello, Bun!' }) + }) +}) diff --git a/tests/integration/auth-flow.test.ts b/tests/integration/auth-flow.test.ts new file mode 100644 index 0000000..fbaa250 --- /dev/null +++ b/tests/integration/auth-flow.test.ts @@ -0,0 +1,57 @@ +import { test, expect, describe, beforeAll, afterAll } from 'bun:test' +import { createTestApp, seedTestUser, cleanupTestData, prisma } from '../helpers' + +const app = createTestApp() + +beforeAll(async () => { + await cleanupTestData() + await seedTestUser('flow@example.com', 'flow123', 'Flow User') +}) + +afterAll(async () => { + await cleanupTestData() + await prisma.$disconnect() +}) + +describe('Full auth flow: login → session → logout → session', () => { + test('complete auth lifecycle', async () => { + // 1. Login + const loginRes = await app.handle(new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'flow@example.com', password: 'flow123' }), + })) + expect(loginRes.status).toBe(200) + + const loginBody = await loginRes.json() + expect(loginBody.user.email).toBe('flow@example.com') + expect(loginBody.user.role).toBe('USER') + + const setCookie = loginRes.headers.get('set-cookie')! + const token = setCookie.match(/session=([^;]+)/)?.[1]! + expect(token).toBeDefined() + + // 2. Check session — should be valid + const sessionRes = await app.handle(new Request('http://localhost/api/auth/session', { + headers: { cookie: `session=${token}` }, + })) + expect(sessionRes.status).toBe(200) + const sessionBody = await sessionRes.json() + expect(sessionBody.user.email).toBe('flow@example.com') + + // 3. Logout + const logoutRes = await app.handle(new Request('http://localhost/api/auth/logout', { + method: 'POST', + headers: { cookie: `session=${token}` }, + })) + expect(logoutRes.status).toBe(200) + + // 4. Check session again — should be invalid + const afterLogoutRes = await app.handle(new Request('http://localhost/api/auth/session', { + headers: { cookie: `session=${token}` }, + })) + expect(afterLogoutRes.status).toBe(401) + const afterLogoutBody = await afterLogoutRes.json() + expect(afterLogoutBody.user).toBeNull() + }) +}) diff --git a/tests/integration/auth-google.test.ts b/tests/integration/auth-google.test.ts new file mode 100644 index 0000000..68abb5f --- /dev/null +++ b/tests/integration/auth-google.test.ts @@ -0,0 +1,42 @@ +import { test, expect, describe, afterAll } from 'bun:test' +import { createTestApp, cleanupTestData, prisma } from '../helpers' + +const app = createTestApp() + +afterAll(async () => { + await cleanupTestData() + await prisma.$disconnect() +}) + +describe('GET /api/auth/google', () => { + test('redirects to Google OAuth', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/google')) + + // Elysia returns 302 for redirects + expect(res.status).toBe(302) + const location = res.headers.get('location') + expect(location).toContain('accounts.google.com/o/oauth2/v2/auth') + expect(location).toContain('client_id=') + expect(location).toContain('redirect_uri=') + expect(location).toContain('scope=openid+email+profile') + expect(location).toContain('response_type=code') + }) + + test('redirect_uri points to callback endpoint', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/google')) + const location = res.headers.get('location')! + const url = new URL(location) + const redirectUri = url.searchParams.get('redirect_uri') + expect(redirectUri).toBe('http://localhost/api/auth/callback/google') + }) +}) + +describe('GET /api/auth/callback/google', () => { + test('redirects to login with error when no code provided', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/callback/google')) + + expect(res.status).toBe(302) + const location = res.headers.get('location') + expect(location).toContain('/login?error=google_failed') + }) +}) diff --git a/tests/integration/auth-login.test.ts b/tests/integration/auth-login.test.ts new file mode 100644 index 0000000..c2261fe --- /dev/null +++ b/tests/integration/auth-login.test.ts @@ -0,0 +1,96 @@ +import { test, expect, describe, beforeAll, afterAll } from 'bun:test' +import { createTestApp, seedTestUser, cleanupTestData, prisma } from '../helpers' + +const app = createTestApp() + +beforeAll(async () => { + await cleanupTestData() + await seedTestUser('admin@example.com', 'admin123', 'Admin') + await seedTestUser('user@example.com', 'user123', 'User') +}) + +afterAll(async () => { + await cleanupTestData() + await prisma.$disconnect() +}) + +describe('POST /api/auth/login', () => { + test('login with valid credentials returns user and session cookie', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@example.com', password: 'admin123' }), + })) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.user).toBeDefined() + expect(body.user.email).toBe('admin@example.com') + expect(body.user.name).toBe('Admin') + expect(body.user.id).toBeDefined() + expect(body.user.role).toBe('USER') + // Should not expose password + expect(body.user.password).toBeUndefined() + + // Check session cookie + const setCookie = res.headers.get('set-cookie') + expect(setCookie).toContain('session=') + expect(setCookie).toContain('HttpOnly') + expect(setCookie).toContain('Path=/') + }) + + test('login with wrong password returns 401', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'admin@example.com', password: 'wrongpassword' }), + })) + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBe('Email atau password salah') + }) + + test('login with non-existent email returns 401', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'nobody@example.com', password: 'anything' }), + })) + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.error).toBe('Email atau password salah') + }) + + test('login returns role field in response', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'user@example.com', password: 'user123' }), + })) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.user.role).toBe('USER') + }) + + test('login creates a session in database', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'user@example.com', password: 'user123' }), + })) + + expect(res.status).toBe(200) + + const setCookie = res.headers.get('set-cookie')! + const token = setCookie.match(/session=([^;]+)/)?.[1] + expect(token).toBeDefined() + + // Verify session exists in DB + const session = await prisma.session.findUnique({ where: { token: token! } }) + expect(session).not.toBeNull() + expect(session!.expiresAt.getTime()).toBeGreaterThan(Date.now()) + }) +}) diff --git a/tests/integration/auth-logout.test.ts b/tests/integration/auth-logout.test.ts new file mode 100644 index 0000000..e2b2e52 --- /dev/null +++ b/tests/integration/auth-logout.test.ts @@ -0,0 +1,80 @@ +import { test, expect, describe, beforeAll, afterAll } from 'bun:test' +import { createTestApp, seedTestUser, createTestSession, cleanupTestData, prisma } from '../helpers' + +const app = createTestApp() + +let testUserId: string + +beforeAll(async () => { + await cleanupTestData() + const user = await seedTestUser('logout-test@example.com', 'pass123', 'Logout Tester') + testUserId = user.id +}) + +afterAll(async () => { + await cleanupTestData() + await prisma.$disconnect() +}) + +describe('POST /api/auth/logout', () => { + test('logout clears session cookie', async () => { + const token = await createTestSession(testUserId) + const res = await app.handle(new Request('http://localhost/api/auth/logout', { + method: 'POST', + headers: { cookie: `session=${token}` }, + })) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.ok).toBe(true) + + // Cookie should be cleared + const setCookie = res.headers.get('set-cookie') + expect(setCookie).toContain('session=;') + expect(setCookie).toContain('Max-Age=0') + }) + + test('logout deletes session from database', async () => { + const token = await createTestSession(testUserId) + + // Verify session exists + let session = await prisma.session.findUnique({ where: { token } }) + expect(session).not.toBeNull() + + await app.handle(new Request('http://localhost/api/auth/logout', { + method: 'POST', + headers: { cookie: `session=${token}` }, + })) + + // Verify session deleted + session = await prisma.session.findUnique({ where: { token } }) + expect(session).toBeNull() + }) + + test('logout without cookie still returns ok', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/logout', { + method: 'POST', + })) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.ok).toBe(true) + }) + + test('session is invalid after logout', async () => { + const token = await createTestSession(testUserId) + + // Logout + await app.handle(new Request('http://localhost/api/auth/logout', { + method: 'POST', + headers: { cookie: `session=${token}` }, + })) + + // Try to use the same session + const sessionRes = await app.handle(new Request('http://localhost/api/auth/session', { + headers: { cookie: `session=${token}` }, + })) + + expect(sessionRes.status).toBe(401) + }) +}) diff --git a/tests/integration/auth-session.test.ts b/tests/integration/auth-session.test.ts new file mode 100644 index 0000000..dc43cd7 --- /dev/null +++ b/tests/integration/auth-session.test.ts @@ -0,0 +1,67 @@ +import { test, expect, describe, beforeAll, afterAll } from 'bun:test' +import { createTestApp, seedTestUser, createTestSession, cleanupTestData, prisma } from '../helpers' + +const app = createTestApp() + +let testUserId: string + +beforeAll(async () => { + await cleanupTestData() + const user = await seedTestUser('session-test@example.com', 'pass123', 'Session Tester') + testUserId = user.id +}) + +afterAll(async () => { + await cleanupTestData() + await prisma.$disconnect() +}) + +describe('GET /api/auth/session', () => { + test('returns 401 without cookie', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/session')) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.user).toBeNull() + }) + + test('returns 401 with invalid token', async () => { + const res = await app.handle(new Request('http://localhost/api/auth/session', { + headers: { cookie: 'session=invalid-token-12345' }, + })) + expect(res.status).toBe(401) + const body = await res.json() + expect(body.user).toBeNull() + }) + + test('returns user with valid session', async () => { + const token = await createTestSession(testUserId) + const res = await app.handle(new Request('http://localhost/api/auth/session', { + headers: { cookie: `session=${token}` }, + })) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.user).toBeDefined() + expect(body.user.email).toBe('session-test@example.com') + expect(body.user.name).toBe('Session Tester') + expect(body.user.id).toBe(testUserId) + expect(body.user.role).toBe('USER') + }) + + test('returns 401 and deletes expired session', async () => { + const expiredDate = new Date(Date.now() - 1000) // 1 second ago + const token = await createTestSession(testUserId, expiredDate) + + const res = await app.handle(new Request('http://localhost/api/auth/session', { + headers: { cookie: `session=${token}` }, + })) + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.user).toBeNull() + + // Verify expired session was deleted from DB + const session = await prisma.session.findUnique({ where: { token } }) + expect(session).toBeNull() + }) +}) diff --git a/tests/integration/error-handling.test.ts b/tests/integration/error-handling.test.ts new file mode 100644 index 0000000..750bca2 --- /dev/null +++ b/tests/integration/error-handling.test.ts @@ -0,0 +1,27 @@ +import { test, expect, describe } from 'bun:test' +import { createTestApp } from '../helpers' + +const app = createTestApp() + +describe('Error handling', () => { + test('unknown API route returns 404 JSON', async () => { + const res = await app.handle(new Request('http://localhost/api/nonexistent')) + expect(res.status).toBe(404) + const body = await res.json() + expect(body).toEqual({ error: 'Not Found', status: 404 }) + }) + + test('unknown nested API route returns 404', async () => { + const res = await app.handle(new Request('http://localhost/api/foo/bar/baz')) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBe('Not Found') + }) + + test('wrong HTTP method returns 404', async () => { + const res = await app.handle(new Request('http://localhost/api/hello', { method: 'DELETE' })) + expect(res.status).toBe(404) + const body = await res.json() + expect(body.error).toBe('Not Found') + }) +}) diff --git a/tests/integration/health.test.ts b/tests/integration/health.test.ts new file mode 100644 index 0000000..d19ffa7 --- /dev/null +++ b/tests/integration/health.test.ts @@ -0,0 +1,13 @@ +import { test, expect, describe } from 'bun:test' +import { createTestApp } from '../helpers' + +const app = createTestApp() + +describe('GET /health', () => { + test('returns 200 with status ok', async () => { + const res = await app.handle(new Request('http://localhost/health')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ status: 'ok' }) + }) +}) diff --git a/tests/unit/db.test.ts b/tests/unit/db.test.ts new file mode 100644 index 0000000..b064959 --- /dev/null +++ b/tests/unit/db.test.ts @@ -0,0 +1,23 @@ +import { test, expect, describe, afterAll } from 'bun:test' +import { prisma } from '../helpers' + +afterAll(async () => { + await prisma.$disconnect() +}) + +describe('Prisma database connection', () => { + test('connects to database', async () => { + const result = await prisma.$queryRaw`SELECT 1 as ok` + expect(result).toEqual([{ ok: 1 }]) + }) + + test('user table exists', async () => { + const count = await prisma.user.count() + expect(typeof count).toBe('number') + }) + + test('session table exists', async () => { + const count = await prisma.session.count() + expect(typeof count).toBe('number') + }) +}) diff --git a/tests/unit/env.test.ts b/tests/unit/env.test.ts new file mode 100644 index 0000000..fe21309 --- /dev/null +++ b/tests/unit/env.test.ts @@ -0,0 +1,34 @@ +import { test, expect, describe } from 'bun:test' + +describe('env', () => { + test('PORT defaults to 3000 when not set', () => { + const original = process.env.PORT + delete process.env.PORT + // Re-import to test default + // Since modules are cached, we test the logic directly + const value = parseInt(process.env.PORT ?? '3000', 10) + expect(value).toBe(3000) + if (original) process.env.PORT = original + }) + + test('PORT parses from env', () => { + const value = parseInt(process.env.PORT ?? '3000', 10) + expect(typeof value).toBe('number') + expect(value).toBeGreaterThan(0) + }) + + test('DATABASE_URL is set', () => { + expect(process.env.DATABASE_URL).toBeDefined() + expect(process.env.DATABASE_URL).toContain('postgresql://') + }) + + test('GOOGLE_CLIENT_ID is set', () => { + expect(process.env.GOOGLE_CLIENT_ID).toBeDefined() + expect(process.env.GOOGLE_CLIENT_ID!.length).toBeGreaterThan(0) + }) + + test('GOOGLE_CLIENT_SECRET is set', () => { + expect(process.env.GOOGLE_CLIENT_SECRET).toBeDefined() + expect(process.env.GOOGLE_CLIENT_SECRET!.length).toBeGreaterThan(0) + }) +}) diff --git a/tests/unit/password.test.ts b/tests/unit/password.test.ts new file mode 100644 index 0000000..9691d21 --- /dev/null +++ b/tests/unit/password.test.ts @@ -0,0 +1,24 @@ +import { test, expect, describe } from 'bun:test' + +describe('Bun.password (bcrypt)', () => { + test('hash and verify correct password', async () => { + const hash = await Bun.password.hash('mypassword', { algorithm: 'bcrypt' }) + expect(hash).toStartWith('$2') + const valid = await Bun.password.verify('mypassword', hash) + expect(valid).toBe(true) + }) + + test('reject wrong password', async () => { + const hash = await Bun.password.hash('mypassword', { algorithm: 'bcrypt' }) + const valid = await Bun.password.verify('wrongpassword', hash) + expect(valid).toBe(false) + }) + + test('different hashes for same password', async () => { + const hash1 = await Bun.password.hash('same', { algorithm: 'bcrypt' }) + const hash2 = await Bun.password.hash('same', { algorithm: 'bcrypt' }) + expect(hash1).not.toBe(hash2) // bcrypt salt differs + expect(await Bun.password.verify('same', hash1)).toBe(true) + expect(await Bun.password.verify('same', hash2)).toBe(true) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..05d8be4 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "sourceMap": true, + "jsx": "react-jsx", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..42bdd96 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,30 @@ +import path from 'node:path' +import { TanStackRouterVite } from '@tanstack/router-vite-plugin' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +export default defineConfig({ + root: process.cwd(), + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + plugins: [ + TanStackRouterVite({ + routesDirectory: './src/frontend/routes', + generatedRouteTree: './src/frontend/routeTree.gen.ts', + routeFileIgnorePrefix: '-', + quoteStyle: 'single', + }), + react(), + ], + build: { + outDir: 'dist', + emptyOutDir: true, + sourcemap: true, + rollupOptions: { + input: path.resolve(__dirname, 'index.html'), + }, + }, +})