From 19ae3146756d67e3c425cd9a8589b4df4e5ef0a6 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Mon, 15 Jun 2026 20:11:12 -0600 Subject: [PATCH] feat: add booksApi client for the books backend --- src/services/booksApi.test.ts | 94 +++++++++++++++++++++++++++++++++++ src/services/booksApi.ts | 72 +++++++++++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/services/booksApi.test.ts create mode 100644 src/services/booksApi.ts diff --git a/src/services/booksApi.test.ts b/src/services/booksApi.test.ts new file mode 100644 index 0000000..a7f08c0 --- /dev/null +++ b/src/services/booksApi.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createEmptyBook } from '../domain/teachingDesign' +import * as booksApi from './booksApi' + +describe('booksApi', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('lists books', async () => { + const summaries = [{ id: 'b1', name: 'Web', updatedAt: '2026-01-01T00:00:00.000Z', lessonCount: 2 }] + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(summaries), { status: 200 })) + + await expect(booksApi.listBooks()).resolves.toEqual(summaries) + expect(fetch).toHaveBeenCalledWith('/api/books', expect.objectContaining({ headers: expect.any(Object) })) + }) + + it('creates a book', async () => { + const created = { id: 'b1', name: '新整本', updatedAt: '2026-01-01T00:00:00.000Z', data: createEmptyBook() } + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(created), { status: 200 })) + + await expect(booksApi.createBook('新整本')).resolves.toEqual(created) + + const [, init] = vi.mocked(fetch).mock.calls[0]! + expect(init?.method).toBe('POST') + expect(JSON.parse(init?.body as string)).toEqual({ name: '新整本' }) + }) + + it('gets a book', async () => { + const record = { id: 'b1', name: 'Web', updatedAt: '2026-01-01T00:00:00.000Z', data: createEmptyBook() } + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(record), { status: 200 })) + + await expect(booksApi.getBook('b1')).resolves.toEqual(record) + expect(fetch).toHaveBeenCalledWith('/api/books/b1', expect.objectContaining({ headers: expect.any(Object) })) + }) + + it('updates a book', async () => { + const meta = { id: 'b1', name: 'Web', updatedAt: '2026-01-02T00:00:00.000Z' } + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(meta), { status: 200 })) + + const data = createEmptyBook() + await expect(booksApi.updateBook('b1', data)).resolves.toEqual(meta) + + const [, init] = vi.mocked(fetch).mock.calls[0]! + expect(init?.method).toBe('PUT') + expect(JSON.parse(init?.body as string)).toEqual({ data }) + }) + + it('renames a book', async () => { + const meta = { id: 'b1', name: '新名称', updatedAt: '2026-01-01T00:00:00.000Z' } + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(meta), { status: 200 })) + + await expect(booksApi.renameBook('b1', '新名称')).resolves.toEqual(meta) + + const [, init] = vi.mocked(fetch).mock.calls[0]! + expect(init?.method).toBe('PATCH') + expect(JSON.parse(init?.body as string)).toEqual({ name: '新名称' }) + }) + + it('deletes a book', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 })) + + await expect(booksApi.deleteBook('b1')).resolves.toEqual({ ok: true }) + + const [, init] = vi.mocked(fetch).mock.calls[0]! + expect(init?.method).toBe('DELETE') + }) + + it('generates a lesson', async () => { + const result = { filename: 'css-flex.md', markdown: '# CSS 弹性布局 教学设计' } + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), { status: 200 })) + + await expect(booksApi.generateLesson('CSS 弹性布局')).resolves.toEqual(result) + + const [, init] = vi.mocked(fetch).mock.calls[0]! + expect(JSON.parse(init?.body as string)).toEqual({ topic: 'CSS 弹性布局' }) + }) + + it('throws the server error message on failure', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ error: '整本不存在。' }), { status: 404 })) + + await expect(booksApi.getBook('missing')).rejects.toThrow('整本不存在。') + }) + + it('throws a generic error when the response has no error message', async () => { + vi.mocked(fetch).mockResolvedValue(new Response('', { status: 500 })) + + await expect(booksApi.getBook('b1')).rejects.toThrow('请求失败(500)') + }) +}) diff --git a/src/services/booksApi.ts b/src/services/booksApi.ts new file mode 100644 index 0000000..c897a42 --- /dev/null +++ b/src/services/booksApi.ts @@ -0,0 +1,72 @@ +import type { TeachingBook } from '../domain/teachingDesign' + +export interface BookSummary { + id: string + name: string + updatedAt: string + lessonCount: number +} + +export interface BookRecord { + id: string + name: string + updatedAt: string + data: TeachingBook +} + +export interface BookMeta { + id: string + name: string + updatedAt: string +} + +export interface GenerateResult { + filename: string + markdown: string +} + +interface ErrorBody { + error?: string +} + +async function request(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + ...init, + headers: { 'Content-Type': 'application/json', ...(init?.headers ?? {}) }, + }) + + if (!response.ok) { + const body = (await response.json().catch(() => null)) as ErrorBody | null + throw new Error(body?.error ?? `请求失败(${response.status})。`) + } + + return response.json() as Promise +} + +export function listBooks(): Promise { + return request('/api/books') +} + +export function createBook(name: string): Promise { + return request('/api/books', { method: 'POST', body: JSON.stringify({ name }) }) +} + +export function getBook(id: string): Promise { + return request(`/api/books/${id}`) +} + +export function updateBook(id: string, data: TeachingBook): Promise { + return request(`/api/books/${id}`, { method: 'PUT', body: JSON.stringify({ data }) }) +} + +export function renameBook(id: string, name: string): Promise { + return request(`/api/books/${id}`, { method: 'PATCH', body: JSON.stringify({ name }) }) +} + +export function deleteBook(id: string): Promise<{ ok: true }> { + return request(`/api/books/${id}`, { method: 'DELETE' }) +} + +export function generateLesson(topic: string): Promise { + return request('/api/generate', { method: 'POST', body: JSON.stringify({ topic }) }) +}