feat: 为 API 增加文章封面字段支持并添加单元测试
This commit is contained in:
1 parent
fdc0af0c10
commit
3a543a7ca5
3 files changed
+203
-7
No files matched your search
@@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ platform, url }) => {
|
|||||||
const { results } = await db
|
const { results } = await db
|
||||||
.prepare(
|
.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
|
FROM posts p
|
||||||
LEFT JOIN categories c ON p.category_id = c.id
|
LEFT JOIN categories c ON p.category_id = c.id
|
||||||
LEFT JOIN series s ON p.series_id = s.id
|
LEFT JOIN series s ON p.series_id = s.id
|
||||||
@@ -46,10 +46,11 @@ export const POST: RequestHandler = async ({ request, platform }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = (await request.json()) as Record<string, unknown>;
|
const data = (await request.json()) as Record<string, unknown>;
|
||||||
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;
|
slug?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
cover?: string | null;
|
||||||
category_slug?: string;
|
category_slug?: string;
|
||||||
series_slug?: string;
|
series_slug?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
@@ -80,14 +81,16 @@ export const POST: RequestHandler = async ({ request, platform }) => {
|
|||||||
if (seriesResult) seriesId = seriesResult.id;
|
if (seriesResult) seriesId = seriesResult.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const postCover = cover ? String(cover).trim() || null : null;
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
INSERT INTO posts (slug, title, content, author_id, category_id, series_id, published_at, updated_at)
|
INSERT INTO posts (slug, title, content, cover, author_id, category_id, series_id, published_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
.bind(slug, title, content, authorId, categoryId, seriesId)
|
.bind(slug, title, content, postCover, authorId, categoryId, seriesId)
|
||||||
.run();
|
.run();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const GET: RequestHandler = async ({ params, platform }) => {
|
|||||||
const post = await db
|
const post = await db
|
||||||
.prepare(
|
.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
|
FROM posts p
|
||||||
LEFT JOIN categories c ON p.category_id = c.id
|
LEFT JOIN categories c ON p.category_id = c.id
|
||||||
LEFT JOIN series s ON p.series_id = s.id
|
LEFT JOIN series s ON p.series_id = s.id
|
||||||
@@ -54,9 +54,10 @@ export const PUT: RequestHandler = async ({ params, request, platform }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const data = (await request.json()) as Record<string, unknown>;
|
const data = (await request.json()) as Record<string, unknown>;
|
||||||
const { title, content, category_slug, series_slug, tags } = data as {
|
const { title, content, cover, category_slug, series_slug, tags } = data as {
|
||||||
title?: string;
|
title?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
|
cover?: string | null;
|
||||||
category_slug?: string;
|
category_slug?: string;
|
||||||
series_slug?: string;
|
series_slug?: string;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
@@ -65,6 +66,7 @@ export const PUT: RequestHandler = async ({ params, request, platform }) => {
|
|||||||
if (
|
if (
|
||||||
!title &&
|
!title &&
|
||||||
!content &&
|
!content &&
|
||||||
|
cover === undefined &&
|
||||||
category_slug === undefined &&
|
category_slug === undefined &&
|
||||||
series_slug === undefined &&
|
series_slug === undefined &&
|
||||||
tags === undefined
|
tags === undefined
|
||||||
@@ -92,6 +94,11 @@ export const PUT: RequestHandler = async ({ params, request, platform }) => {
|
|||||||
updates.push('content = ?');
|
updates.push('content = ?');
|
||||||
bindings.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) {
|
if (category_slug !== undefined) {
|
||||||
let categoryId = null;
|
let categoryId = null;
|
||||||
if (category_slug) {
|
if (category_slug) {
|
||||||
|
|||||||
@@ -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<typeof getPosts>[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<typeof createPost>[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<typeof getPostDetail>[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<typeof updatePost>[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<typeof updatePost>[0];
|
||||||
|
|
||||||
|
const res2 = await updatePost(clearEvent);
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
expect(boundArgs[0]).toContain(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user