From 3a543a7ca5c4e4952273fec42f01032f0b64b1a8 Mon Sep 17 00:00:00 2001 From: seaHi Date: Wed, 7 Oct 2026 10:33:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=BA=20API=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=96=87=E7=AB=A0=E5=B0=81=E9=9D=A2=E5=AD=97=E6=AE=B5=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=B9=B6=E6=B7=BB=E5=8A=A0=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/api/v1/posts/+server.ts | 13 +- src/routes/api/v1/posts/[slug]/+server.ts | 11 +- src/routes/api/v1/posts/posts.test.ts | 186 ++++++++++++++++++++++ 3 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 src/routes/api/v1/posts/posts.test.ts diff --git a/src/routes/api/v1/posts/+server.ts b/src/routes/api/v1/posts/+server.ts index fdd97a8..a9b082b 100644 --- a/src/routes/api/v1/posts/+server.ts +++ b/src/routes/api/v1/posts/+server.ts @@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ platform, url }) => { const { results } = await db .prepare( ` - SELECT p.id, p.slug, p.title, p.published_at, p.updated_at, c.slug as category_slug, c.name as category_name, s.slug as series_slug, s.name as series_name + SELECT p.id, p.slug, p.title, p.cover, p.published_at, p.updated_at, c.slug as category_slug, c.name as category_name, s.slug as series_slug, s.name as series_name FROM posts p LEFT JOIN categories c ON p.category_id = c.id LEFT JOIN series s ON p.series_id = s.id @@ -46,10 +46,11 @@ export const POST: RequestHandler = async ({ request, platform }) => { try { const data = (await request.json()) as Record; - const { slug, title, content, category_slug, series_slug, tags } = data as { + const { slug, title, content, cover, category_slug, series_slug, tags } = data as { slug?: string; title?: string; content?: string; + cover?: string | null; category_slug?: string; series_slug?: string; tags?: string[]; @@ -80,14 +81,16 @@ export const POST: RequestHandler = async ({ request, platform }) => { if (seriesResult) seriesId = seriesResult.id; } + const postCover = cover ? String(cover).trim() || null : null; + const result = await db .prepare( ` - INSERT INTO posts (slug, title, content, author_id, category_id, series_id, published_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + INSERT INTO posts (slug, title, content, cover, author_id, category_id, series_id, published_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ` ) - .bind(slug, title, content, authorId, categoryId, seriesId) + .bind(slug, title, content, postCover, authorId, categoryId, seriesId) .run(); if (result.success) { diff --git a/src/routes/api/v1/posts/[slug]/+server.ts b/src/routes/api/v1/posts/[slug]/+server.ts index 160686c..58bfd4e 100644 --- a/src/routes/api/v1/posts/[slug]/+server.ts +++ b/src/routes/api/v1/posts/[slug]/+server.ts @@ -11,7 +11,7 @@ export const GET: RequestHandler = async ({ params, platform }) => { const post = await db .prepare( ` - SELECT p.id, p.slug, p.title, p.content, p.published_at, p.updated_at, c.slug as category_slug, c.name as category_name, s.slug as series_slug, s.name as series_name + SELECT p.id, p.slug, p.title, p.content, p.cover, p.published_at, p.updated_at, c.slug as category_slug, c.name as category_name, s.slug as series_slug, s.name as series_name FROM posts p LEFT JOIN categories c ON p.category_id = c.id LEFT JOIN series s ON p.series_id = s.id @@ -54,9 +54,10 @@ export const PUT: RequestHandler = async ({ params, request, platform }) => { try { const data = (await request.json()) as Record; - const { title, content, category_slug, series_slug, tags } = data as { + const { title, content, cover, category_slug, series_slug, tags } = data as { title?: string; content?: string; + cover?: string | null; category_slug?: string; series_slug?: string; tags?: string[]; @@ -65,6 +66,7 @@ export const PUT: RequestHandler = async ({ params, request, platform }) => { if ( !title && !content && + cover === undefined && category_slug === undefined && series_slug === undefined && tags === undefined @@ -92,6 +94,11 @@ export const PUT: RequestHandler = async ({ params, request, platform }) => { updates.push('content = ?'); bindings.push(content); } + if (cover !== undefined) { + updates.push('cover = ?'); + const postCover = cover ? String(cover).trim() || null : null; + bindings.push(postCover); + } if (category_slug !== undefined) { let categoryId = null; if (category_slug) { diff --git a/src/routes/api/v1/posts/posts.test.ts b/src/routes/api/v1/posts/posts.test.ts new file mode 100644 index 0000000..5a7248c --- /dev/null +++ b/src/routes/api/v1/posts/posts.test.ts @@ -0,0 +1,186 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it, vi } from 'vitest'; +import { GET as getPosts, POST as createPost } from './+server'; +import { GET as getPostDetail, PUT as updatePost } from './[slug]/+server'; + +describe('API v1 posts cover support', () => { + it('GET /api/v1/posts includes cover in SQL select query', async () => { + const mockAll = vi.fn().mockResolvedValue({ + results: [ + { + id: 1, + slug: 'test', + title: 'Test', + cover: 'https://example.com/cover.png', + published_at: '2026-01-01', + updated_at: '2026-01-01' + } + ] + }); + const mockBind = vi.fn().mockReturnValue({ all: mockAll }); + const mockPrepare = vi.fn((sql: string) => { + if (sql.includes('COUNT(*)')) { + return { all: vi.fn().mockResolvedValue({ results: [{ total: 1 }] }) }; + } + return { bind: mockBind }; + }); + + const event = { + platform: { + env: { + DB: { prepare: mockPrepare } + } + }, + url: new URL('https://example.com/api/v1/posts?limit=10&offset=0') + } as unknown as Parameters[0]; + + const response = await getPosts(event); + const json = (await response.json()) as any; + + expect(mockPrepare.mock.calls[0][0]).toContain('p.cover'); + expect(json.data[0].cover).toBe('https://example.com/cover.png'); + }); + + it('POST /api/v1/posts saves cover into database', async () => { + const boundArgs: unknown[][] = []; + const mockRun = vi.fn().mockResolvedValue({ success: true }); + const mockBind = vi.fn((...args: unknown[]) => { + boundArgs.push(args); + return { run: mockRun, first: vi.fn().mockResolvedValue({ id: 1 }) }; + }); + const mockPrepare = vi.fn((sql: string) => { + void sql; + return { + bind: mockBind, + run: mockRun, + first: vi.fn().mockResolvedValue({ id: 1 }) + }; + }); + + const event = { + platform: { + env: { + DB: { prepare: mockPrepare } + } + }, + request: new Request('https://example.com/api/v1/posts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug: 'new-post', + title: 'New Post', + content: 'Hello world', + cover: ' https://example.com/banner.jpg ' + }) + }) + } as unknown as Parameters[0]; + + const response = await createPost(event); + expect(response.status).toBe(200); + + const insertCall = mockPrepare.mock.calls.find((call) => + (call[0] as string).includes('INSERT INTO posts') + ); + expect(insertCall).toBeDefined(); + expect(insertCall![0]).toContain('cover'); + expect(boundArgs[0]).toContain('https://example.com/banner.jpg'); + }); + + it('GET /api/v1/posts/[slug] queries and returns cover', async () => { + const mockFirst = vi.fn().mockResolvedValue({ + id: 1, + slug: 'my-post', + title: 'My Post', + content: 'Body', + cover: 'https://example.com/pic.jpg', + published_at: '2026-01-01', + updated_at: '2026-01-01' + }); + const mockPrepare = vi.fn((sql: string) => { + if (sql.includes('SELECT t.name')) { + return { + bind: vi.fn().mockReturnValue({ all: vi.fn().mockResolvedValue({ results: [] }) }) + }; + } + return { bind: vi.fn().mockReturnValue({ first: mockFirst }) }; + }); + + const event = { + params: { slug: 'my-post' }, + platform: { + env: { + DB: { prepare: mockPrepare } + } + } + } as unknown as Parameters[0]; + + const response = await getPostDetail(event); + const json = (await response.json()) as any; + + expect(mockPrepare.mock.calls[0][0]).toContain('p.cover'); + expect(json.data.cover).toBe('https://example.com/pic.jpg'); + }); + + it('PUT /api/v1/posts/[slug] updates or clears cover', async () => { + const boundArgs: unknown[][] = []; + const mockRun = vi.fn().mockResolvedValue({ success: true }); + const mockBind = vi.fn((...args: unknown[]) => { + boundArgs.push(args); + return { run: mockRun }; + }); + const mockPrepare = vi.fn((sql: string) => { + if (sql.includes('SELECT id FROM posts')) { + return { bind: vi.fn().mockReturnValue({ first: vi.fn().mockResolvedValue({ id: 1 }) }) }; + } + return { bind: mockBind }; + }); + + // 1. Update cover with new value + const updateEvent = { + params: { slug: 'my-post' }, + platform: { + env: { + DB: { prepare: mockPrepare } + } + }, + request: new Request('https://example.com/api/v1/posts/my-post', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cover: 'https://example.com/new-cover.jpg' + }) + }) + } as unknown as Parameters[0]; + + const res1 = await updatePost(updateEvent); + expect(res1.status).toBe(200); + const updateCall1 = mockPrepare.mock.calls.find((call) => + (call[0] as string).includes('UPDATE posts') + ); + expect(updateCall1![0]).toContain('cover = ?'); + expect(boundArgs[0]).toContain('https://example.com/new-cover.jpg'); + + // 2. Clear cover by passing null or empty string + boundArgs.length = 0; + mockPrepare.mockClear(); + const clearEvent = { + params: { slug: 'my-post' }, + platform: { + env: { + DB: { prepare: mockPrepare } + } + }, + request: new Request('https://example.com/api/v1/posts/my-post', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + cover: '' + }) + }) + } as unknown as Parameters[0]; + + const res2 = await updatePost(clearEvent); + expect(res2.status).toBe(200); + expect(boundArgs[0]).toContain(null); + }); +});