Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"editor.tabSize": 2
}
7 changes: 5 additions & 2 deletions docs/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,11 @@ paths:
$ref: '#/components/schemas/BookSummary'
total:
type: integer
page:
currentPage:
type: integer
totalPages:
type: integer
description: 総ページ数
limit:
type: integer
next:
Expand Down Expand Up @@ -406,7 +409,7 @@ components:
schema:
type: string
PublishedBy:
name: published_by
name: publishedBy
in: query
description: 出版社名
schema:
Expand Down
2 changes: 2 additions & 0 deletions frontend/.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
VITE_API_BASE_URL=http://localhost:8000/api
VITE_API_MOCK_URL=http://127.0.0.1:3658/m1/1205854-1201194-default/api
2 changes: 1 addition & 1 deletion frontend/.prettierrc
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 80
}
}
54 changes: 54 additions & 0 deletions frontend/App.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Header } from '@/components/layouts/Header'
import HomePage from '@/app/page'
import BookListPage from '@/app/books/page'
import BookDetailPage from '@/app/books/[bookId]/page'
import ReviewCreatePage from '@/app/reviews/create/page'
import ReviewEditPage from '@/app/reviews/edit/[reviewId]/page'
import UserCreatePage from '@/app/users/create/page'
import UserLoginPage from '@/app/users/login/page'
import UserDetailPage from '@/app/users/[userId]/page'
import UserEditPage from '@/app/users/edit/[userId]/page'
import PasswordResetMailSendPage from '@/app/users/password/reset/mail/send/page'
import PasswordResetFormPage from '@/app/users/password/reset/form/page'
import PasswordResetCompletePage from '@/app/users/password/reset/complete/page'
import EmailResetFormPage from '@/app/users/email/reset/form/page'
import EmailConfirmPage from '@/app/confirm/email/page'

export function App() {
return (
<BrowserRouter>
<Header />
<main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/books" element={<BookListPage />} />
<Route path="/books/:bookId" element={<BookDetailPage />} />
<Route path="/reviews/create" element={<ReviewCreatePage />} />
<Route path="/reviews/edit/:reviewId" element={<ReviewEditPage />} />
<Route path="/users/create" element={<UserCreatePage />} />
<Route path="/users/login" element={<UserLoginPage />} />
<Route
path="/users/password/reset/mail/send"
element={<PasswordResetMailSendPage />}
/>
<Route
path="/users/password/reset/form"
element={<PasswordResetFormPage />}
/>
<Route
path="/users/password/reset/complete"
element={<PasswordResetCompletePage />}
/>
<Route
path="/users/email/reset/form"
element={<EmailResetFormPage />}
/>
<Route path="/confirm/email" element={<EmailConfirmPage />} />
<Route path="/users/:userId" element={<UserDetailPage />} />
<Route path="/users/edit/:userId" element={<UserEditPage />} />
</Routes>
</main>
</BrowserRouter>
)
}
1 change: 1 addition & 0 deletions frontend/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Vite の規約に従い `VITE_` プレフィックスを使用。
## コマンド

`frontend/` ディレクトリ内で実行してください。

```sh
pnpm dev # vite(開発サーバー)
pnpm build # vite build
Expand Down
103 changes: 103 additions & 0 deletions frontend/api/books.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'

// vi.hoisted は静的インポートの評価より前に実行されるため、
// import.meta.env.VITE_API_MOCK_URL を事前に設定できる
vi.hoisted(() => {
import.meta.env.VITE_API_MOCK_URL = 'http://api.example.com'
})

import { fetchBooks } from '@/api/books'

const mockFetch = vi.fn()
globalThis.fetch = mockFetch

const successResponse = {
books: [{ id: 1, title: 'テスト書籍' }],
total: 1,
currentPage: 1,
next: false,
prev: false,
}

function makeFetchResponse(body: unknown, ok = true, status = 200) {
return {
ok,
status,
json: () => Promise.resolve(body),
}
}

describe('fetchBooks', () => {
beforeEach(() => {
mockFetch.mockResolvedValue(makeFetchResponse(successResponse))
})

afterEach(() => {
mockFetch.mockReset()
})

it('calls fetch once', async () => {
await fetchBooks()
expect(mockFetch).toHaveBeenCalledTimes(1)
})

it('calls the correct endpoint path', async () => {
await fetchBooks()
const url = mockFetch.mock.calls[0][0] as string
expect(url).toContain('/api/books')
})

it('appends title query param when provided', async () => {
await fetchBooks({ title: 'React入門' })
const url = mockFetch.mock.calls[0][0] as string
const params = new URL(url).searchParams
expect(params.get('title')).toBe('React入門')
})

it('appends author query param when provided', async () => {
await fetchBooks({ author: '山田太郎' })
const url = mockFetch.mock.calls[0][0] as string
const params = new URL(url).searchParams
expect(params.get('author')).toBe('山田太郎')
})

it('appends publishedBy query param when provided', async () => {
await fetchBooks({ publishedBy: 'テスト出版社' })
const url = mockFetch.mock.calls[0][0] as string
const params = new URL(url).searchParams
expect(params.get('publishedBy')).toBe('テスト出版社')
})

it('appends page and limit query params when provided', async () => {
await fetchBooks({ page: 3, limit: 20 })
const url = mockFetch.mock.calls[0][0] as string
const params = new URL(url).searchParams
expect(params.get('page')).toBe('3')
expect(params.get('limit')).toBe('20')
})

it('does not append params when they are undefined', async () => {
await fetchBooks({ title: undefined, author: undefined })
const url = mockFetch.mock.calls[0][0] as string
const params = new URL(url).searchParams
expect(params.get('title')).toBeNull()
expect(params.get('author')).toBeNull()
})

it('returns the parsed JSON response', async () => {
const result = await fetchBooks()
expect(result).toEqual(successResponse)
})

it('throws an error when response status is not ok', async () => {
mockFetch.mockResolvedValueOnce(makeFetchResponse({}, false, 500))
await expect(fetchBooks()).rejects.toThrow(
'書籍一覧の取得に失敗しました: 500',
)
})

it('throws an error with the correct status code in the message', async () => {
mockFetch.mockResolvedValueOnce(makeFetchResponse({}, false, 404))
await expect(fetchBooks()).rejects.toThrow('404')
})
})
37 changes: 37 additions & 0 deletions frontend/api/books.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { paths, components } from '@/types/api'

/** 書籍サマリーの型 */
export type BookSummary = components['schemas']['BookSummary']

/** 書籍一覧APIのクエリパラメータ型 */
type BooksQuery = NonNullable<paths['/api/books']['get']['parameters']['query']>

/** 書籍一覧APIのレスポンス型 */
type BooksResponse =
paths['/api/books']['get']['responses'][200]['content']['application/json']

/** APIのベースURL */
const API_BASE_URL = import.meta.env.VITE_API_MOCK_URL
// const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000'

/**
* 書籍一覧を取得する
* @param params - 検索クエリパラメータ(タイトル・著者・出版社・ページ・件数)
* @returns 書籍一覧レスポンス
* @throws 取得に失敗した場合にエラーをスロー
*/
export async function fetchBooks(params?: BooksQuery): Promise<BooksResponse> {
const url = new URL('api/books', API_BASE_URL)
if (params?.title) url.searchParams.set('title', params.title)
if (params?.author) url.searchParams.set('author', params.author)
if (params?.publishedBy)
url.searchParams.set('publishedBy', params.publishedBy)
if (params?.page != null) url.searchParams.set('page', String(params.page))
if (params?.limit != null) url.searchParams.set('limit', String(params.limit))

const res = await fetch(url.toString())
if (!res.ok) {
throw new Error(`書籍一覧の取得に失敗しました: ${res.status}`)
}
return res.json() as Promise<BooksResponse>
}
9 changes: 4 additions & 5 deletions frontend/app/books/[bookId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
type Props = {
params: Promise<{ bookId: string }>
}
import { useParams } from 'react-router-dom'

export default async function BookDetailPage({ params }: Props) {
const { bookId } = await params
/** 書籍詳細ページ */
export default function BookDetailPage() {
const { bookId } = useParams<{ bookId: string }>()

return (
<main>
Expand Down
Loading