打通「使用者」從資料層到服務層、API 路由與頁面展示的最小流程,並建立可重複運行的測試環境與單元測試。
createUser
(Email/Password 註冊、Zod 驗證、bcrypt 雜湊)、getUsers
(依建立時間排序)findByEmail
、insertUser
、listByCreatedAt
POST /api/users
、GET /api/users
,以錯誤代碼對應 HTTP 狀態碼.env.test
、migrator 打通;新增 service 與 API handler 測試users.created_at
補上索引(後續會補 migration 與游標分頁)這次用到的主要套件:
zod
、drizzle-zod
、bcryptjs
vitest
、@vitest/ui
、@vitest/coverage-v8
package.json
(節錄 Scripts)
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"coverage": "vitest run --coverage"
}
}
這次在 users.created_at
補上索引,並維持以 drizzle-zod 產生 insert/select schema,方便型別與驗證整合。
src/lib/db/schema.ts
import { pgTable, timestamp, uuid, varchar, index } from 'drizzle-orm/pg-core';
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
export const users = pgTable(
'users',
{
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
password: varchar('password', { length: 255 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
},
(table) => [index('users_created_at_idx').on(table.createdAt)]
);
export const insertUserSchema = createInsertSchema(users);
export const selectUserSchema = createSelectSchema(users);
備註:目前 migration 尚未包含此索引,後續會補上
generate
以納入版控;此處先以語義層定義。
先定義通用的 Result
型別,統一錯誤處理與回傳格式:
src/server/types.ts
export type Ok<T> = { ok: true; data: T };
export type Err = { ok: false; code: string; message: string };
export type Result<T> = Ok<T> | Err;
export const ok = <T>(data: T): Ok<T> => ({ ok: true, data });
export const err = (code: string, message: string): Err => ({
ok: false,
code,
message,
});
Repository 封裝資料庫操作,維持服務層的單一職責:
src/server/repos/usersRepo.ts
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { desc, eq } from 'drizzle-orm';
export async function findByEmail(email: string) {
const rows = await db.select().from(users).where(eq(users.email, email)).limit(1);
return rows[0] ?? null;
}
export async function insertUser(row: typeof users.$inferInsert) {
const [inserted] = await db.insert(users).values(row).returning();
return inserted;
}
export async function listByCreatedAt(limit = 20, _cursor?: string | null) {
void _cursor;
const rows = await db.select().from(users).orderBy(desc(users.createdAt)).limit(limit);
return { items: rows, nextCursor: null } as const; // 游標先簡化,後續實作
}
服務層處理驗證與雜湊,並轉為公開可回傳的資料形狀:
src/server/users.ts
'use server';
import { z } from 'zod';
import bcrypt from 'bcryptjs';
import { users } from '@/lib/db/schema';
import { createInsertSchema } from 'drizzle-zod';
import { ok, err, type Result } from './types';
import { findByEmail, insertUser, listByCreatedAt } from './repos/usersRepo';
export type User = typeof users.$inferSelect;
export type UserPublic = Omit<User, 'password'>;
export type PageResult<T> = { items: T[]; nextCursor: string | null };
const baseInsert = createInsertSchema(users);
const CreateUserInput = baseInsert.extend({
email: z.string().email('Email 格式錯誤'),
password: z.string().min(8, '密碼至少 8 碼'),
});
export type CreateUserInput = z.infer<typeof CreateUserInput>;
function toPublic(u: User): UserPublic {
const { password: _password, ...rest } = u;
void _password;
return rest;
}
export async function createUser(input: unknown): Promise<Result<UserPublic>> {
const parsed = CreateUserInput.safeParse(input);
if (!parsed.success) return err('VALIDATION_ERROR', parsed.error.message);
const exists = await findByEmail(parsed.data.email);
if (exists) return err('EMAIL_TAKEN', 'Email 已被註冊');
const hashed = await bcrypt.hash(parsed.data.password, 10);
const inserted = await insertUser({ ...parsed.data, password: hashed });
return ok(toPublic(inserted));
}
export async function getUsers(
limit = 20,
cursor?: string | null
): Promise<Result<PageResult<UserPublic>>> {
const page = await listByCreatedAt(limit, cursor);
return ok({ items: page.items.map(toPublic), nextCursor: page.nextCursor });
}
依 Result.code
對應適當的 HTTP 狀態碼,並處理例外為 500。
src/app/api/users/route.ts
import { NextResponse } from 'next/server';
import { createUser, getUsers } from '@/server/users';
export const runtime = 'nodejs';
const statusOf = (code: string) =>
code === 'VALIDATION_ERROR' ? 400 : code === 'EMAIL_TAKEN' ? 409 : 500;
export async function POST(req: Request) {
try {
const body = await req.json().catch(() => ({}));
const result = await createUser(body);
const status = result.ok ? 200 : statusOf(result.code);
return NextResponse.json(result, { status });
} catch {
return NextResponse.json(
{ ok: false, code: 'INTERNAL_ERROR', message: 'Internal error' },
{ status: 500 }
);
}
}
export async function GET() {
try {
const result = await getUsers(20, null);
const status = result.ok ? 200 : statusOf(result.code);
return NextResponse.json(result, { status });
} catch {
return NextResponse.json(
{ ok: false, code: 'INTERNAL_ERROR', message: 'Internal error' },
{ status: 500 }
);
}
}
用最簡單的 server component 列出使用者,驗證端到端串接。
src/app/page.tsx
export const revalidate = 0;
export const runtime = 'nodejs';
import { getUsers } from '@/server/users';
export default async function Page() {
const r = await getUsers(20, null);
if (!r.ok) return <div>讀取失敗:{r.message}</div>;
return (
<main className="mx-auto max-w-3xl p-6">
<h1 className="mb-4 text-2xl font-semibold">Users</h1>
<ul className="space-y-2">
{r.data.items.map((u) => (
<li key={u.id} className="text-sm">
<span className="font-medium">{u.name}</span> — {u.email}
</li>
))}
</ul>
</main>
);
}
以 .env.test
指向測試資料庫,並在 Vitest 啟動時載入:
vitest.setup.ts
import { config } from 'dotenv';
config({ path: '.env.test' });
.env.test
DATABASE_URL=postgresql://postgres:password@localhost:5432/copygram_test
Service 層測試:驗證雜湊密碼、重複 email 錯誤碼;並以 migrator 建立表、每次測試清表。
tests/service.users.test.ts
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { db, closeDb } from '@/lib/db';
import { users } from '@/lib/db/schema';
import { createUser } from '@/server/users';
import { eq } from 'drizzle-orm';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
beforeAll(async () => {
await migrate(db, {
migrationsFolder: 'scripts/db/migrations',
migrationsSchema: 'public',
});
await db.delete(users);
});
afterAll(async () => {
await closeDb();
});
afterEach(async () => {
await db.delete(users);
});
describe('service: createUser', () => {
it('應寫入雜湊密碼且回傳不含 password', async () => {
const r = await createUser({
email: 'svc@example.com',
name: 'Svc',
password: 'password123',
});
expect(r.ok).toBe(true);
if (r.ok) {
// @ts-expect-error ensure no password on returned data
expect(r.data.password).toBeUndefined();
const row = await db.select().from(users).where(eq(users.email, 'svc@example.com')).limit(1);
expect(row[0]?.password).toMatch(/^\$2[aby]\$.{56}$/);
}
});
it('重複 email 應回 EMAIL_TAKEN', async () => {
await createUser({ email: 'dup@example.com', name: 'A', password: 'password123' });
const r2 = await createUser({ email: 'dup@example.com', name: 'B', password: 'password123' });
expect(r2.ok).toBe(false);
if (!r2.ok) expect(r2.code).toBe('EMAIL_TAKEN');
});
});
API Handler 測試:以 vi.mock
假資料,驗證狀態碼對應與輸出格式。
tests/api.users.post.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { POST } from '@/app/api/users/route';
type MockCreateUserBody = Partial<{ email: string; password: string; name?: string }>;
type MockCreateUserOk = {
ok: true;
data: { id: string; email: string; name: string; createdAt: string; updatedAt: string };
};
type MockCreateUserErr = { ok: false; code: string; message: string };
type MockCreateUserResult = MockCreateUserOk | MockCreateUserErr;
vi.mock('@/server/users', () => {
return {
createUser: vi.fn(async (body: MockCreateUserBody): Promise<MockCreateUserResult> => {
if (!body?.email || !body?.password)
return { ok: false, code: 'VALIDATION_ERROR', message: 'invalid' } as const;
if (body.email === 'taken@example.com')
return { ok: false, code: 'EMAIL_TAKEN', message: 'taken' } as const;
return {
ok: true as const,
data: {
id: 'u_1',
email: body.email!,
name: body.name ?? '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
};
}),
};
});
describe('POST /api/users', () => {
beforeEach(() => vi.clearAllMocks());
it('應回 200 並帶 Result.ok', async () => {
const req = new Request('http://localhost/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'ok@example.com', password: 'password123', name: 'Ok' }),
});
const res = await POST(req);
expect(res.status).toBe(200);
});
it('重複 email 應回 409', async () => {
const req = new Request('http://localhost/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'taken@example.com', password: 'password123' }),
});
const res = await POST(req);
expect(res.status).toBe(409);
});
it('驗證錯誤應回 400', async () => {
const req = new Request('http://localhost/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '', password: '' }),
});
const res = await POST(req);
expect(res.status).toBe(400);
});
});
執行:
npm run test # 一次跑完
npm run test:watch # 監看模式
npm run test:ui # UI 模式