iT邦幫忙

2025 iThome 鐵人賽

DAY 3
0
佛心分享-SideProject30

Road To Full-Stack:前端轉全端的 Instagram 挑戰系列 第 3

Road To Full-Stack:前端轉全端的 Instagram 挑戰 - Day 3

  • 分享至 

  • xImage
  •  

本日目標

打通「使用者」從資料層到服務層、API 路由與頁面展示的最小流程,並建立可重複運行的測試環境與單元測試。

功能概覽

  • 服務層:createUser(Email/Password 註冊、Zod 驗證、bcrypt 雜湊)、getUsers(依建立時間排序)
  • 資料存取:以 repository 模式封裝 findByEmailinsertUserlistByCreatedAt
  • API 路由:新增 POST /api/usersGET /api/users,以錯誤代碼對應 HTTP 狀態碼
  • 頁面:在首頁展示使用者清單(暫以 server component 直取)
  • 測試:導入 Vitest,本機 .env.test、migrator 打通;新增 service 與 API handler 測試
  • Schema 微調:為 users.created_at 補上索引(後續會補 migration 與游標分頁)

套件與腳本

這次用到的主要套件:

  • Runtime/工具:zoddrizzle-zodbcryptjs
  • 測試:vitest@vitest/ui@vitest/coverage-v8

package.json(節錄 Scripts)

{
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "test:ui": "vitest --ui",
    "coverage": "vitest run --coverage"
  }
}

資料庫 Schema 微調

這次在 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 以納入版控;此處先以語義層定義。


服務層與 Repository

先定義通用的 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 });
}

API 路由

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 模式

上一篇
Road To Full-Stack:前端轉全端的 Instagram 挑戰 - Day 2
下一篇
Road To Full-Stack:前端轉全端的 Instagram 挑戰 - Day 4
系列文
Road To Full-Stack:前端轉全端的 Instagram 挑戰5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言