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 <noreply@anthropic.com>
This commit is contained in:
26
tests/e2e/api-hello.test.ts
Normal file
26
tests/e2e/api-hello.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect, describe } from 'bun:test'
|
||||
import { createPage } from './browser'
|
||||
|
||||
describe('E2E: Hello API via browser', () => {
|
||||
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()
|
||||
}
|
||||
})
|
||||
})
|
||||
35
tests/e2e/auth-api.test.ts
Normal file
35
tests/e2e/auth-api.test.ts
Normal file
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
162
tests/e2e/browser.ts
Normal file
162
tests/e2e/browser.ts
Normal file
@@ -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<string, any>
|
||||
result?: Record<string, any>
|
||||
error?: { code: number; message: string }
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export class LightpandaPage {
|
||||
private ws: WebSocket
|
||||
private sessionId: string
|
||||
private idCounter = 1
|
||||
private pending = new Map<number, { resolve: (v: any) => void; reject: (e: Error) => void }>()
|
||||
private ready: Promise<void>
|
||||
|
||||
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<string, any> = {}): Promise<any> {
|
||||
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<void> {
|
||||
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<T = any>(expression: string): Promise<T> {
|
||||
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<string> {
|
||||
return this.evaluate('document.title')
|
||||
}
|
||||
|
||||
async textContent(selector: string): Promise<string | null> {
|
||||
return this.evaluate(`document.querySelector('${selector}')?.textContent ?? null`)
|
||||
}
|
||||
|
||||
async getAttribute(selector: string, attr: string): Promise<string | null> {
|
||||
return this.evaluate(`document.querySelector('${selector}')?.getAttribute('${attr}') ?? null`)
|
||||
}
|
||||
|
||||
async querySelectorAll(selector: string, property = 'textContent'): Promise<string[]> {
|
||||
return this.evaluate(`Array.from(document.querySelectorAll('${selector}')).map(el => el.${property})`)
|
||||
}
|
||||
|
||||
async url(): Promise<string> {
|
||||
return this.evaluate('window.location.href')
|
||||
}
|
||||
|
||||
async getResponseBody(path: string): Promise<string> {
|
||||
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<void> {
|
||||
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<void>((resolve, reject) => {
|
||||
ws.onopen = () => resolve()
|
||||
ws.onerror = () => reject(new Error(`Cannot connect to Lightpanda at ${WS_ENDPOINT}`))
|
||||
})
|
||||
|
||||
// Create target
|
||||
const targetId = await new Promise<string>((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<string>((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 {}
|
||||
},
|
||||
}
|
||||
}
|
||||
27
tests/e2e/google-oauth.test.ts
Normal file
27
tests/e2e/google-oauth.test.ts
Normal file
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
15
tests/e2e/health.test.ts
Normal file
15
tests/e2e/health.test.ts
Normal file
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
49
tests/e2e/landing-page.test.ts
Normal file
49
tests/e2e/landing-page.test.ts
Normal file
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
59
tests/e2e/login-page.test.ts
Normal file
59
tests/e2e/login-page.test.ts
Normal file
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
38
tests/helpers.ts
Normal file
38
tests/helpers.ts
Normal file
@@ -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()
|
||||
}
|
||||
27
tests/integration/api-hello.test.ts
Normal file
27
tests/integration/api-hello.test.ts
Normal file
@@ -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!' })
|
||||
})
|
||||
})
|
||||
57
tests/integration/auth-flow.test.ts
Normal file
57
tests/integration/auth-flow.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
42
tests/integration/auth-google.test.ts
Normal file
42
tests/integration/auth-google.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
96
tests/integration/auth-login.test.ts
Normal file
96
tests/integration/auth-login.test.ts
Normal file
@@ -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())
|
||||
})
|
||||
})
|
||||
80
tests/integration/auth-logout.test.ts
Normal file
80
tests/integration/auth-logout.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
67
tests/integration/auth-session.test.ts
Normal file
67
tests/integration/auth-session.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
27
tests/integration/error-handling.test.ts
Normal file
27
tests/integration/error-handling.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
13
tests/integration/health.test.ts
Normal file
13
tests/integration/health.test.ts
Normal file
@@ -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' })
|
||||
})
|
||||
})
|
||||
23
tests/unit/db.test.ts
Normal file
23
tests/unit/db.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
34
tests/unit/env.test.ts
Normal file
34
tests/unit/env.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
24
tests/unit/password.test.ts
Normal file
24
tests/unit/password.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user