支持自定义字段搜索并保护模板读取接口
This commit is contained in:
parent
ab73b5ec75
commit
0b5bd8510a
114
app/api/home-filter-fields/__tests__/route-auth.test.ts
Normal file
114
app/api/home-filter-fields/__tests__/route-auth.test.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { GET } from '../route'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
$queryRaw: jest.fn(),
|
||||
$queryRawUnsafe: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/field-pool', () => ({
|
||||
getFieldPoolRecords: jest.fn(),
|
||||
syncFieldPoolFromTemplates: jest.fn(),
|
||||
formatFieldOptionEntries: jest.fn((options) => options),
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
const { getFieldPoolRecords, syncFieldPoolFromTemplates, formatFieldOptionEntries } = require('@/lib/field-pool')
|
||||
|
||||
describe('GET /api/home-filter-fields 权限边界', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
formatFieldOptionEntries.mockImplementation((options: Array<{ label: string; value: string }>) => options)
|
||||
})
|
||||
|
||||
it('未登录时拒绝读取首页筛选字段', async () => {
|
||||
getServerSession.mockResolvedValue(null)
|
||||
|
||||
const response = await GET()
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('请先登录')
|
||||
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
|
||||
expect(getFieldPoolRecords).not.toHaveBeenCalled()
|
||||
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('已登录时正常读取首页筛选字段', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'user-1', role: 'USER' },
|
||||
})
|
||||
getFieldPoolRecords.mockResolvedValue([
|
||||
{
|
||||
key: 'categoryName',
|
||||
label: '使用店铺',
|
||||
filterDisplayName: '店铺',
|
||||
isFilterEnabled: true,
|
||||
options: [{ label: '店铺A', value: '店铺A' }],
|
||||
},
|
||||
])
|
||||
prisma.$queryRawUnsafe
|
||||
.mockResolvedValueOnce([{ value: '店铺A', count: BigInt(2) }])
|
||||
.mockResolvedValueOnce([{ count: BigInt(3) }])
|
||||
|
||||
const response = await GET()
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual({
|
||||
fields: [
|
||||
{
|
||||
key: 'categoryName',
|
||||
label: '店铺',
|
||||
totalCount: 3,
|
||||
options: [{ label: '店铺A', value: '店铺A', count: 2 }],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('自定义首页筛选字段会从 metadata.templateFields 统计数量', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'user-1', role: 'USER' },
|
||||
})
|
||||
getFieldPoolRecords.mockResolvedValue([
|
||||
{
|
||||
key: 'customCode',
|
||||
label: '自定义编码',
|
||||
filterDisplayName: '编码',
|
||||
isFilterEnabled: true,
|
||||
options: [{ label: 'CODE-001', value: 'CODE-001' }],
|
||||
},
|
||||
])
|
||||
prisma.$queryRaw
|
||||
.mockResolvedValueOnce([{ value: 'CODE-001', count: BigInt(2) }])
|
||||
.mockResolvedValueOnce([{ count: BigInt(2) }])
|
||||
|
||||
const response = await GET()
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2)
|
||||
expect(prisma.$queryRawUnsafe).not.toHaveBeenCalled()
|
||||
expect(data).toEqual({
|
||||
fields: [
|
||||
{
|
||||
key: 'customCode',
|
||||
label: '编码',
|
||||
totalCount: 2,
|
||||
options: [{ label: 'CODE-001', value: 'CODE-001', count: 2 }],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,4 +1,7 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getFieldPoolRecords, syncFieldPoolFromTemplates, formatFieldOptionEntries } from '@/lib/field-pool'
|
||||
|
||||
@ -20,6 +23,19 @@ const MATERIAL_FIELD_COLUMN_MAP: Record<string, string> = {
|
||||
authorName: 'authorName',
|
||||
}
|
||||
|
||||
function metadataTemplateFieldsJson() {
|
||||
return Prisma.sql`COALESCE(
|
||||
(
|
||||
CASE
|
||||
WHEN "metadata" IS NOT NULL AND "metadata" ~ '^\\s*\\{'
|
||||
THEN "metadata"::jsonb
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
) -> 'templateFields',
|
||||
'{}'::jsonb
|
||||
)`
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建每个字段的素材统计
|
||||
* @returns counts: 每个选项的素材数量, totals: 每个字段有非空值的素材总数
|
||||
@ -31,7 +47,33 @@ async function buildMaterialCounts(fieldKeys: string[]) {
|
||||
await Promise.all(
|
||||
fieldKeys.map(async (fieldKey) => {
|
||||
const column = MATERIAL_FIELD_COLUMN_MAP[fieldKey]
|
||||
if (!column) return
|
||||
if (!column) {
|
||||
const rows = await prisma.$queryRaw<Array<{ value: string | null; count: bigint }>>(Prisma.sql`
|
||||
SELECT (${metadataTemplateFieldsJson()} ->> ${fieldKey}) AS value, COUNT(*)::bigint AS count
|
||||
FROM "materials"
|
||||
WHERE (${metadataTemplateFieldsJson()} ->> ${fieldKey}) IS NOT NULL
|
||||
AND (${metadataTemplateFieldsJson()} ->> ${fieldKey}) != ''
|
||||
AND "deletedAt" IS NULL
|
||||
GROUP BY (${metadataTemplateFieldsJson()} ->> ${fieldKey})
|
||||
`)
|
||||
|
||||
const map = new Map<string, number>()
|
||||
rows.forEach((row) => {
|
||||
if (!row.value) return
|
||||
map.set(row.value, Number(row.count ?? 0))
|
||||
})
|
||||
counts[fieldKey] = map
|
||||
|
||||
const totalResult = await prisma.$queryRaw<Array<{ count: bigint }>>(Prisma.sql`
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM "materials"
|
||||
WHERE (${metadataTemplateFieldsJson()} ->> ${fieldKey}) IS NOT NULL
|
||||
AND (${metadataTemplateFieldsJson()} ->> ${fieldKey}) != ''
|
||||
AND "deletedAt" IS NULL
|
||||
`)
|
||||
totals[fieldKey] = Number(totalResult[0]?.count ?? 0)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取每个选项的素材数量(排除已删除的素材)
|
||||
const rows = await prisma.$queryRawUnsafe<Array<{ value: string | null; count: bigint }>>(
|
||||
@ -57,6 +99,14 @@ async function buildMaterialCounts(fieldKeys: string[]) {
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
await syncFieldPoolFromTemplates()
|
||||
const fields = (await getFieldPoolRecords()) as Array<{
|
||||
key: string
|
||||
|
||||
141
app/api/materials/__tests__/materials-list-auth.test.ts
Normal file
141
app/api/materials/__tests__/materials-list-auth.test.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { GET } from '../route'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
material: {
|
||||
count: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
uploadField: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
fieldDefinition: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
$queryRaw: jest.fn(),
|
||||
materialLike: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
materialFavorite: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/url-rewrite', () => ({
|
||||
rewriteMaterialStorageUrlsForClient: jest.fn((material) => material),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/material-log', () => ({
|
||||
createMaterialLog: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/s3', () => ({
|
||||
deleteFromS3: jest.fn(),
|
||||
isS3Url: jest.fn(() => false),
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
|
||||
describe('GET /api/materials 权限边界', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('未登录时拒绝读取素材列表', async () => {
|
||||
getServerSession.mockResolvedValue(null)
|
||||
|
||||
const response = await GET(new NextRequest('http://localhost/api/materials?pageSize=1'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('请先登录')
|
||||
expect(prisma.material.count).not.toHaveBeenCalled()
|
||||
expect(prisma.material.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('已登录时正常读取素材列表', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'user-1', role: 'USER' },
|
||||
})
|
||||
prisma.material.count.mockResolvedValue(0)
|
||||
prisma.material.findMany.mockResolvedValue([])
|
||||
prisma.uploadField.findMany.mockResolvedValue([])
|
||||
prisma.fieldDefinition.findMany.mockResolvedValue([])
|
||||
|
||||
const response = await GET(new NextRequest('http://localhost/api/materials?pageSize=1'))
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data.materials).toEqual([])
|
||||
expect(data.totalCount).toBe(0)
|
||||
expect(prisma.material.count).toHaveBeenCalled()
|
||||
expect(prisma.material.findMany).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('搜索时会把可搜索自定义字段纳入查询', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'user-1', role: 'USER' },
|
||||
})
|
||||
prisma.uploadField.findMany.mockResolvedValue([
|
||||
{ key: 'customCode' },
|
||||
{ key: 'productModel' },
|
||||
])
|
||||
prisma.$queryRaw.mockResolvedValue([{ id: 'material-custom-1' }])
|
||||
prisma.fieldDefinition.findMany.mockResolvedValue([])
|
||||
prisma.material.count.mockResolvedValue(1)
|
||||
prisma.material.findMany.mockResolvedValue([])
|
||||
|
||||
const response = await GET(new NextRequest('http://localhost/api/materials?search=CODE-001&pageSize=1'))
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(prisma.uploadField.findMany).toHaveBeenCalledWith({
|
||||
where: { isSearchable: true },
|
||||
select: { key: true },
|
||||
})
|
||||
expect(prisma.$queryRaw).toHaveBeenCalled()
|
||||
expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
OR: expect.arrayContaining([{ id: { in: ['material-custom-1'] } }]),
|
||||
}),
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('首页自定义字段筛选会按 metadata 字段值过滤素材', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'user-1', role: 'USER' },
|
||||
})
|
||||
prisma.fieldDefinition.findMany.mockResolvedValue([{ key: 'customCode' }])
|
||||
prisma.$queryRaw.mockResolvedValue([{ id: 'material-custom-1' }])
|
||||
prisma.material.count.mockResolvedValue(1)
|
||||
prisma.material.findMany.mockResolvedValue([])
|
||||
|
||||
const response = await GET(
|
||||
new NextRequest('http://localhost/api/materials?homeField_customCode=CODE-001&pageSize=1')
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(prisma.fieldDefinition.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
key: { in: ['customCode'] },
|
||||
isFilterEnabled: true,
|
||||
},
|
||||
select: { key: true },
|
||||
})
|
||||
expect(prisma.material.count.mock.calls[0][0].where.AND).toEqual(
|
||||
expect.arrayContaining([{ id: { in: ['material-custom-1'] } }])
|
||||
)
|
||||
})
|
||||
})
|
||||
@ -1,10 +1,12 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { rewriteMaterialStorageUrlsForClient } from '@/lib/url-rewrite'
|
||||
import { createMaterialLog } from '@/lib/material-log'
|
||||
import { buildDepartmentDescendants, isDepartmentWithinScope } from '@/lib/departments'
|
||||
import { isStandardField } from '@/lib/field-data-storage'
|
||||
import {
|
||||
collectMaterialStorageUrls,
|
||||
deleteMaterialStorageFiles,
|
||||
@ -55,10 +57,94 @@ function buildMaterialSnapshot(material: {
|
||||
}
|
||||
}
|
||||
|
||||
function metadataTemplateFieldsJson() {
|
||||
return Prisma.sql`COALESCE(
|
||||
(
|
||||
CASE
|
||||
WHEN "metadata" IS NOT NULL AND "metadata" ~ '^\\s*\\{'
|
||||
THEN "metadata"::jsonb
|
||||
ELSE '{}'::jsonb
|
||||
END
|
||||
) -> 'templateFields',
|
||||
'{}'::jsonb
|
||||
)`
|
||||
}
|
||||
|
||||
async function getSearchableCustomFieldKeys(): Promise<string[]> {
|
||||
const fields = await prisma.uploadField.findMany({
|
||||
where: {
|
||||
isSearchable: true,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
})
|
||||
|
||||
return Array.from(
|
||||
new Set(fields.map((field) => field.key).filter((key) => !isStandardField(key)))
|
||||
)
|
||||
}
|
||||
|
||||
async function getEnabledCustomHomeFilterKeys(fieldKeys: string[]): Promise<Set<string>> {
|
||||
const customKeys = fieldKeys.filter((key) => !ALLOWED_DYNAMIC_FILTER_FIELDS.has(key))
|
||||
if (customKeys.length === 0) {
|
||||
return new Set()
|
||||
}
|
||||
|
||||
const enabledFields = await prisma.fieldDefinition.findMany({
|
||||
where: {
|
||||
key: { in: customKeys },
|
||||
isFilterEnabled: true,
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
},
|
||||
})
|
||||
|
||||
return new Set(enabledFields.map((field) => field.key))
|
||||
}
|
||||
|
||||
async function findMaterialIdsByCustomFieldSearch(search: string): Promise<string[]> {
|
||||
const fieldKeys = await getSearchableCustomFieldKeys()
|
||||
if (fieldKeys.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const rows = await prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
|
||||
SELECT "id"
|
||||
FROM "materials"
|
||||
WHERE EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_each_text(${metadataTemplateFieldsJson()}) AS field("key", "value")
|
||||
WHERE field."key" IN (${Prisma.join(fieldKeys)})
|
||||
AND field."value" ILIKE ${`%${search}%`}
|
||||
)
|
||||
`)
|
||||
|
||||
return rows.map((row) => row.id)
|
||||
}
|
||||
|
||||
async function findMaterialIdsByCustomFieldValue(fieldKey: string, value: string): Promise<string[]> {
|
||||
const rows = await prisma.$queryRaw<Array<{ id: string }>>(Prisma.sql`
|
||||
SELECT "id"
|
||||
FROM "materials"
|
||||
WHERE ${metadataTemplateFieldsJson()} ->> ${fieldKey} = ${value}
|
||||
`)
|
||||
|
||||
return rows.map((row) => row.id)
|
||||
}
|
||||
|
||||
// Get all materials with search and filter support
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(req.url)
|
||||
const search = searchParams.get('search') || ''
|
||||
const categoryName = searchParams.get('category') || ''
|
||||
@ -73,14 +159,25 @@ export async function GET(req: NextRequest) {
|
||||
const showDeleted = searchParams.get('showDeleted') || ''
|
||||
const year = searchParams.get('year') || ''
|
||||
const dynamicFieldFilters: Array<{ key: string; value: string }> = []
|
||||
const requestedDynamicFieldKeys: string[] = []
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key.startsWith('homeField_') && value) {
|
||||
const fieldKey = key.replace('homeField_', '')
|
||||
requestedDynamicFieldKeys.push(fieldKey)
|
||||
if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(fieldKey)) {
|
||||
dynamicFieldFilters.push({ key: fieldKey, value })
|
||||
}
|
||||
}
|
||||
})
|
||||
const enabledCustomHomeFilterKeys = await getEnabledCustomHomeFilterKeys(requestedDynamicFieldKeys)
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key.startsWith('homeField_') && value) {
|
||||
const fieldKey = key.replace('homeField_', '')
|
||||
if (enabledCustomHomeFilterKeys.has(fieldKey)) {
|
||||
dynamicFieldFilters.push({ key: fieldKey, value })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Build where clause
|
||||
const where: any = {}
|
||||
@ -99,6 +196,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
// Add search condition (search in SPU, shop, scene category, author)
|
||||
if (search) {
|
||||
const customSearchMaterialIds = await findMaterialIdsByCustomFieldSearch(search)
|
||||
andConditions.push({
|
||||
OR: [
|
||||
{ productModel: { contains: search, mode: 'insensitive' } }, // SPU
|
||||
@ -109,6 +207,9 @@ export async function GET(req: NextRequest) {
|
||||
{ categoryName: { contains: search, mode: 'insensitive' } }, // 使用店铺
|
||||
{ sceneName: { contains: search, mode: 'insensitive' } }, // 场景类别
|
||||
{ authorName: { contains: search, mode: 'insensitive' } }, // 作者
|
||||
...(customSearchMaterialIds.length > 0
|
||||
? [{ id: { in: customSearchMaterialIds } }]
|
||||
: []),
|
||||
]
|
||||
})
|
||||
}
|
||||
@ -191,9 +292,14 @@ export async function GET(req: NextRequest) {
|
||||
}
|
||||
}
|
||||
|
||||
dynamicFieldFilters.forEach(({ key, value }) => {
|
||||
andConditions.push({ [key]: value } as Record<string, string>)
|
||||
})
|
||||
for (const { key, value } of dynamicFieldFilters) {
|
||||
if (ALLOWED_DYNAMIC_FILTER_FIELDS.has(key)) {
|
||||
andConditions.push({ [key]: value } as Record<string, string>)
|
||||
} else {
|
||||
const materialIds = await findMaterialIdsByCustomFieldValue(key, value)
|
||||
andConditions.push({ id: { in: materialIds } })
|
||||
}
|
||||
}
|
||||
|
||||
// 合并所有条件
|
||||
if (andConditions.length > 0) {
|
||||
@ -486,6 +592,7 @@ export async function DELETE(req: NextRequest) {
|
||||
},
|
||||
materialSnapshot: buildMaterialSnapshot(material),
|
||||
})
|
||||
|
||||
// 永久删除数据库记录
|
||||
await prisma.material.delete({
|
||||
where: { id },
|
||||
@ -521,5 +628,3 @@ export async function DELETE(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
58
app/api/templates/__tests__/templates-route-auth.test.ts
Normal file
58
app/api/templates/__tests__/templates-route-auth.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { GET } from '../route'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
$queryRaw: jest.fn(),
|
||||
uploadTemplate: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
|
||||
describe('GET /api/templates 权限边界', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('未登录时拒绝读取模板列表', async () => {
|
||||
getServerSession.mockResolvedValue(null)
|
||||
|
||||
const response = await GET()
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('请先登录')
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled()
|
||||
expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('已登录时正常读取模板列表', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'user-1', role: 'USER' },
|
||||
})
|
||||
prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: '["3dmax-material"]' }])
|
||||
prisma.uploadTemplate.findMany.mockResolvedValue([
|
||||
{ id: 'custom-template', name: '自定义模板' },
|
||||
])
|
||||
|
||||
const response = await GET()
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toEqual([
|
||||
{ id: 'builtin-3dmax-material', name: '3DMAX 素材模板', isBuiltin: true },
|
||||
{ id: 'custom-template', name: '自定义模板', isBuiltin: false },
|
||||
])
|
||||
})
|
||||
})
|
||||
@ -6,37 +6,24 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
// 预制模板配置
|
||||
const BUILTIN_TEMPLATE_KEYS = [
|
||||
'3dmax-material',
|
||||
'psd-material',
|
||||
'audio-material',
|
||||
'video-material',
|
||||
'model-material',
|
||||
'other-material',
|
||||
]
|
||||
|
||||
const BUILTIN_TEMPLATE_NAMES: Record<string, string> = {
|
||||
'3dmax-material': '3DMAX 素材模板',
|
||||
'psd-material': 'PSD 素材模板',
|
||||
'audio-material': '音频素材模板',
|
||||
'video-material': '视频素材模板',
|
||||
'model-material': '模特素材模板',
|
||||
'other-material': '其他素材模板',
|
||||
}
|
||||
|
||||
function createBuiltinTemplate(key: string) {
|
||||
return {
|
||||
id: `builtin-${key}`,
|
||||
name: BUILTIN_TEMPLATE_NAMES[key] || key,
|
||||
isBuiltin: true,
|
||||
}
|
||||
}
|
||||
import {
|
||||
BUILTIN_TEMPLATE_KEYS,
|
||||
createBuiltinTemplate,
|
||||
} from '@/lib/upload-template-presets'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取启用的预制模板列表
|
||||
let enabledBuiltinTemplates: string[] = []
|
||||
const ALL_BUILTIN_KEYS = BUILTIN_TEMPLATE_KEYS // 所有预制模板key
|
||||
@ -78,7 +65,14 @@ export async function GET() {
|
||||
// 如果 enabledBuiltinTemplates 为空数组,表示全部启用
|
||||
const builtinTemplates = BUILTIN_TEMPLATE_KEYS
|
||||
.filter(key => enabledBuiltinTemplates.length === 0 || enabledBuiltinTemplates.includes(key))
|
||||
.map(key => createBuiltinTemplate(key))
|
||||
.map(key => {
|
||||
const template = createBuiltinTemplate(key)
|
||||
return {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
isBuiltin: true,
|
||||
}
|
||||
})
|
||||
|
||||
// 合并预制模板和自定义模板,预制模板在前
|
||||
const allTemplates = [
|
||||
|
||||
63
app/api/upload-templates/__tests__/route-auth.test.ts
Normal file
63
app/api/upload-templates/__tests__/route-auth.test.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { GET } from '../route'
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
authOptions: {},
|
||||
}))
|
||||
|
||||
jest.mock('next-auth', () => ({
|
||||
getServerSession: jest.fn(),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/prisma', () => ({
|
||||
prisma: {
|
||||
$queryRaw: jest.fn(),
|
||||
uploadTemplate: {
|
||||
findMany: jest.fn(),
|
||||
},
|
||||
material: {
|
||||
count: jest.fn(),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const { getServerSession } = require('next-auth')
|
||||
const { prisma } = require('@/lib/prisma')
|
||||
|
||||
describe('GET /api/upload-templates 权限边界', () => {
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks()
|
||||
})
|
||||
|
||||
it('未登录时拒绝读取上传模板', async () => {
|
||||
getServerSession.mockResolvedValue(null)
|
||||
|
||||
const response = await GET()
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(data.error).toBe('请先登录')
|
||||
expect(prisma.$queryRaw).not.toHaveBeenCalled()
|
||||
expect(prisma.uploadTemplate.findMany).not.toHaveBeenCalled()
|
||||
expect(prisma.material.count).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('已登录时正常读取上传模板', async () => {
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: 'user-1', role: 'USER' },
|
||||
})
|
||||
prisma.$queryRaw.mockResolvedValue([{ enabledBuiltinTemplates: '["other-material"]' }])
|
||||
prisma.uploadTemplate.findMany.mockResolvedValue([])
|
||||
prisma.material.count.mockResolvedValue(0)
|
||||
|
||||
const response = await GET()
|
||||
const data = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveLength(1)
|
||||
expect(data[0]).toMatchObject({
|
||||
id: 'builtin-other-material',
|
||||
isBuiltin: true,
|
||||
materialCount: 0,
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -7,6 +7,8 @@
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/lib/auth'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
BUILTIN_TEMPLATE_KEYS,
|
||||
@ -55,6 +57,14 @@ const publicSelect = {
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session?.user) {
|
||||
return NextResponse.json(
|
||||
{ error: '请先登录' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// 获取启用的预制模板列表
|
||||
let enabledBuiltinTemplates: string[] = []
|
||||
try {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user