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:
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' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user