fix: default certificate expiry to one year after testing

This commit is contained in:
WuYongJie 2026-09-27 16:06:27 +08:00
parent cb510075fc
commit d2337b960e
15 changed files with 164 additions and 33 deletions

View File

@ -13,7 +13,7 @@ import {
import { updateMaterialWithSharedRules, type MaterialUpdateInput } from '@/lib/material-update';
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { isWebFileLike } from '@/lib/web-file';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType, isPublicCertificateReportPdf } from '@/lib/certificate-report';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType, getDefaultCertificateExpiryDate, isCertificateReportMaterialType, isPublicCertificateReportPdf } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
import { is3DOriginalType, isProductPackageType } from '@/lib/material-type-mapper';
@ -481,7 +481,11 @@ export async function GET(
reportKind: materialWithUser.reportKind || null,
certificateTestedOn: materialWithUser.certificateTestedOn || null,
certificateNumber: materialWithUser.certificateNumber || null,
certificateExpiresOn: materialWithUser.certificateExpiresOn || null,
certificateExpiresOn: materialWithUser.certificateExpiresOn || (
isCertificateReportMaterialType(materialWithUser.materialType)
? getDefaultCertificateExpiryDate(materialWithUser.certificateTestedOn)
: null
),
reportUsageRules: materialWithUser.reportUsageRules || null,
categoryName: materialWithUser.categoryName,
sceneName: materialWithUser.sceneName,

View File

@ -14,7 +14,7 @@ import {
import { isProductPackageType, is3DOriginalType, needsAssetsList } from '@/lib/material-type-mapper';
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set';
import { canManageMaterial } from '@/lib/material-authorization';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType, isPublicCertificateReportPdf } from '@/lib/certificate-report';
import { detectFileKindFromName, getCertificateReportFileKindByAssetType, getDefaultCertificateExpiryDate, isCertificateReportMaterialType, isPublicCertificateReportPdf } from '@/lib/certificate-report';
import { DownloadErrorCode } from '@/lib/download-errors';
import { getVideoPreviewPolicyFields, userHasVideoEarlyAccess } from '@/lib/video-download-policy';
@ -364,7 +364,11 @@ export async function GET(request: NextRequest) {
reportKind: (material as any).reportKind || null,
certificateTestedOn: (material as any).certificateTestedOn || null,
certificateNumber: (material as any).certificateNumber || null,
certificateExpiresOn: (material as any).certificateExpiresOn || null,
certificateExpiresOn: (material as any).certificateExpiresOn || (
isCertificateReportMaterialType(material.materialType as unknown as string)
? getDefaultCertificateExpiryDate((material as any).certificateTestedOn)
: null
),
reportUsageRules: (material as any).reportUsageRules || null,
isAiGenerated: Boolean((material as any).isAiGenerated),
taskDetailSourceType: (material as any).taskDetailSourceType || null,

View File

@ -68,4 +68,13 @@ describe('检测报告初始化', () => {
expect(response.status).toBe(400)
expect(prisma.material.create).not.toHaveBeenCalled()
})
test.each(['', undefined, null])('到期时间留空时写入按检测日期计算的日期 (%s)', async (certificateExpiresOn) => {
const response = await POST(request({ ...input, certificateTestedOn: '2024-02-29', certificateExpiresOn }))
expect(response.status).toBe(200)
expect(prisma.material.create).toHaveBeenCalledWith({ data: expect.objectContaining({
certificateTestedOn: new Date('2024-02-29T00:00:00.000Z'),
certificateExpiresOn: new Date('2025-02-28T00:00:00.000Z'),
}) })
})
})

View File

@ -23,6 +23,7 @@ import {
CERTIFICATE_REPORT_KIND,
CERTIFICATE_REPORT_PLATFORM_OPTIONS,
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
getDefaultCertificateExpiryDate,
isCertificateReportMaterialType,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
@ -112,6 +113,7 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
const [certificateTestedOn, setCertificateTestedOn] = useState('')
const [certificateNumber, setCertificateNumber] = useState('')
const [certificateExpiresOn, setCertificateExpiresOn] = useState('')
const defaultExpiryDate = getDefaultCertificateExpiryDate(certificateTestedOn)?.toISOString().slice(0, 10) || ''
const [reportUsageRules, setReportUsageRules] = useState('')
const [liveDate, setLiveDate] = useState('')
const [liveHost, setLiveHost] = useState('')
@ -1069,8 +1071,9 @@ export default function EditMaterialPage({ params }: { params: { id: string } })
<p className="mt-1 text-xs text-gray-500">请按报告标注的检测日期选择</p>
</div>
<div>
<label htmlFor={`edit-material-${params.id}-certificate-expires-on`} className="mb-2 block text-sm font-medium text-gray-700">证书到期时间 *</label>
<CertificateDatePicker id={`edit-material-${params.id}-certificate-expires-on`} label="证书到期时间" value={certificateExpiresOn} onChange={setCertificateExpiresOn} disabled={submitting} />
<label htmlFor={`edit-material-${params.id}-certificate-expires-on`} className="mb-2 block text-sm font-medium text-gray-700">证书到期时间(选填)</label>
<CertificateDatePicker id={`edit-material-${params.id}-certificate-expires-on`} label="证书到期时间" value={certificateExpiresOn} onChange={setCertificateExpiresOn} disabled={submitting} suggestedDate={defaultExpiryDate} placeholder={defaultExpiryDate ? `默认 ${defaultExpiryDate}` : '留空自动计算'} />
<p className="mt-1 text-xs text-gray-500">留空按检测日期顺延一年,也可自行选择日期</p>
</div>
</div>
<div>

View File

@ -240,7 +240,7 @@ describe('certificate report share page', () => {
global.fetch = originalFetch
})
test('详情展示证书字段和未拆分的完整说明', async () => {
test.each([false, true])('详情展示证书字段、完整说明并按权限提供修改日期入口 (%s)', async (canEdit) => {
global.fetch = jest.fn().mockResolvedValue(createJsonResponse({
id: 'video-material-1',
userId: 'user-1',
@ -252,6 +252,7 @@ describe('certificate report share page', () => {
certificateTestedOn: '2027-09-23T00:00:00.000Z',
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对主体',
capabilities: { canEdit, canFavorite: false, canDelete: false },
reportUsageRules: '第一段\n第二段',
categoryName: '通用',
sceneName: '通用',
@ -275,6 +276,12 @@ describe('certificate report share page', () => {
expect(screen.queryByText('使用前核对主体')).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '检测报告使用规则说明' }).nextElementSibling?.textContent)
.toBe('第一段\n第二段')
if (canEdit) {
await userEvent.setup().click(screen.getByRole('button', { name: '修改日期' }))
expect(mockPush).toHaveBeenCalledWith('/edit/video-material-1')
} else {
expect(screen.queryByRole('button', { name: '修改日期' })).not.toBeInTheDocument()
}
})
})

View File

@ -2086,8 +2086,13 @@ export default function SharePage() {
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{material.certificateTestedOn ? String(material.certificateTestedOn).slice(0, 10) : '待补充'}</p>
</div>
<div>
<p className="mb-1 text-[12px] text-[#86898e]">证书到期时间</p>
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{material.certificateExpiresOn ? String(material.certificateExpiresOn).slice(0, 10) : '待补充'}</p>
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<p className="text-[12px] text-[#86898e]">证书到期时间</p>
{material.capabilities?.canEdit && (
<button type="button" onClick={() => router.push(`/edit/${material.id}`)} className="min-h-9 rounded-[6px] px-2 text-xs font-medium text-[#315f91] hover:bg-[#eef4fb] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">修改日期</button>
)}
</div>
<p className="font-en text-[15px] font-medium tabular-nums text-[#1a1c1d]">{material.certificateExpiresOn ? String(material.certificateExpiresOn).slice(0, 10) : '请先补充检测日期'}</p>
</div>
<div>
<p className="mb-1 text-[12px] text-[#86898e]">证书编号</p>

View File

@ -18,20 +18,23 @@ interface CertificateDatePickerProps {
value: string
onChange: (value: string) => void
disabled?: boolean
placeholder?: string
suggestedDate?: string
}
export default function CertificateDatePicker({ id, label, value, onChange, disabled = false }: CertificateDatePickerProps) {
export default function CertificateDatePicker({ id, label, value, onChange, disabled = false, placeholder = '选择日期', suggestedDate = '' }: CertificateDatePickerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const calendarRef = useRef<HTMLDivElement>(null)
const selectedDate = useMemo(() => parseDate(value), [value])
const calendarDate = useMemo(() => selectedDate || parseDate(suggestedDate), [selectedDate, suggestedDate])
const [isOpen, setIsOpen] = useState(false)
const [isMonthPickerOpen, setIsMonthPickerOpen] = useState(false)
const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(selectedDate || new Date()))
const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(calendarDate || new Date()))
useEffect(() => {
if (selectedDate) setVisibleMonth(startOfMonth(selectedDate))
}, [selectedDate])
if (calendarDate) setVisibleMonth(startOfMonth(calendarDate))
}, [calendarDate])
useEffect(() => {
if (!isOpen) return
@ -92,7 +95,7 @@ export default function CertificateDatePicker({ id, label, value, onChange, disa
id={id}
type="button"
disabled={disabled}
aria-label={`${label},${selectedDate ? format(selectedDate, 'yyyy年M月d日') : '未选择'}`}
aria-label={`${label},${selectedDate ? format(selectedDate, 'yyyy年M月d日') : suggestedDate ? placeholder : '未选择'}`}
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-controls={`${id}-calendar`}
@ -103,7 +106,7 @@ export default function CertificateDatePicker({ id, label, value, onChange, disa
className="input-apple flex min-h-[50px] items-center justify-between gap-3 text-left disabled:cursor-not-allowed disabled:opacity-60"
>
<span className={selectedDate ? 'text-[#1a1c1d]' : 'text-gray-500'}>
{selectedDate ? format(selectedDate, 'yyyy年M月d日') : '选择日期'}
{selectedDate ? format(selectedDate, 'yyyy年M月d日') : placeholder}
</span>
<CalendarDays className="h-[18px] w-[18px] shrink-0 text-[#5c6470]" aria-hidden="true" />
</button>

View File

@ -5,7 +5,7 @@ import { DEFAULT_CERTIFICATE_REPORT_USAGE_RULES } from '@/lib/certificate-report
jest.mock('@/lib/chunk-upload', () => ({ chunkUpload: jest.fn() }))
test.each(['成品', '原材料'])('%s上传只提交一段说明,并分别提交检测日期和到期时间', async (kind) => {
test.each([['成品', true], ['成品', false], ['原材料', true], ['原材料', false]] as const)('%s上传说明与日期:手动到期时间 %s', async (kind, manualExpiry) => {
const originalFetch = global.fetch
jest.useFakeTimers().setSystemTime(new Date('2026-09-27T12:00:00'))
const { container } = render(<CertificateReportUploader onSuccess={jest.fn()} onCancel={jest.fn()} productCategories={[{ id: '1', name: '板材' }]} />)
@ -17,11 +17,11 @@ test.each(['成品', '原材料'])('%s上传只提交一段说明,并分别提
fireEvent.change(screen.getByLabelText('证书编号 *'), { target: { value: 'CMA-001' } })
fireEvent.click(screen.getByRole('button', { name: /证书检测日期/ }))
fireEvent.click(screen.getByRole('button', { name: '2026年9月23日' }))
fireEvent.click(screen.getByRole('button', { name: /证书到期时间/ }))
fireEvent.click(screen.getByRole('button', { name: '选择年份和月份' }))
fireEvent.click(screen.getByRole('button', { name: '下一年' }))
fireEvent.click(screen.getByRole('button', { name: '9月' }))
fireEvent.click(screen.getByRole('button', { name: '2027年9月23日' }))
expect(screen.getByLabelText('证书到期时间(选填)')).toHaveTextContent('默认 2027-09-23')
if (manualExpiry) {
fireEvent.click(screen.getByRole('button', { name: /证书到期时间/ }))
fireEvent.click(screen.getByRole('button', { name: '2027年9月24日' }))
}
expect(screen.queryByLabelText('证书使用说明 *')).not.toBeInTheDocument()
expect(screen.getAllByRole('textbox').filter((input) => input.tagName === 'TEXTAREA')).toHaveLength(1)
expect(screen.getByLabelText('检测报告使用规则说明 *')).toHaveValue(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES)
@ -35,7 +35,7 @@ test.each(['成品', '原材料'])('%s上传只提交一段说明,并分别提
const [url, init] = (global.fetch as jest.Mock).mock.calls[0]
expect(url).toBe('/api/materials/upload/certificate-report/init')
const payload = JSON.parse(init.body)
expect(payload).toMatchObject({ reportKind: kind, certificateTestedOn: '2026-09-23', certificateExpiresOn: '2027-09-23', reportUsageRules: DEFAULT_CERTIFICATE_REPORT_USAGE_RULES })
expect(payload).toMatchObject({ reportKind: kind, certificateTestedOn: '2026-09-23', certificateExpiresOn: manualExpiry ? '2027-09-24' : '', reportUsageRules: DEFAULT_CERTIFICATE_REPORT_USAGE_RULES })
expect(payload).not.toHaveProperty('certificateUsageInstructions')
} finally {
jest.useRealTimers()

View File

@ -7,6 +7,7 @@ import {
CERTIFICATE_REPORT_PLATFORM_OPTIONS,
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
detectFileKindFromName,
getDefaultCertificateExpiryDate,
validateCertificateReportMetadata,
} from '@/lib/certificate-report'
import CertificateDatePicker from '@/components/upload/CertificateDatePicker'
@ -42,6 +43,7 @@ export default function CertificateReportUploader({
const [certificateTestedOn, setCertificateTestedOn] = useState('')
const [certificateNumber, setCertificateNumber] = useState('')
const [certificateExpiresOn, setCertificateExpiresOn] = useState('')
const defaultExpiryDate = getDefaultCertificateExpiryDate(certificateTestedOn)?.toISOString().slice(0, 10) || ''
const [reportUsageRules, setReportUsageRules] = useState(DEFAULT_CERTIFICATE_REPORT_USAGE_RULES)
const [productCategoryName, setProductCategoryName] = useState('')
const [platformName, setPlatformName] = useState('')
@ -351,8 +353,9 @@ export default function CertificateReportUploader({
<p className="mt-1 text-xs text-gray-500">请按报告标注的检测日期选择</p>
</div>
<div>
<label htmlFor="certificate-report-expires-on" className={fieldLabelClass}>证书到期时间 *</label>
<CertificateDatePicker id="certificate-report-expires-on" label="证书到期时间" value={certificateExpiresOn} onChange={setCertificateExpiresOn} disabled={uploading} />
<label htmlFor="certificate-report-expires-on" className={fieldLabelClass}>证书到期时间(选填)</label>
<CertificateDatePicker id="certificate-report-expires-on" label="证书到期时间" value={certificateExpiresOn} onChange={setCertificateExpiresOn} disabled={uploading} suggestedDate={defaultExpiryDate} placeholder={defaultExpiryDate ? `默认 ${defaultExpiryDate}` : '留空自动计算'} />
<p className="mt-1 text-xs text-gray-500">留空按检测日期顺延一年,也可自行选择日期</p>
</div>
</div>

View File

@ -1,6 +1,7 @@
import {
DEFAULT_CERTIFICATE_REPORT_USAGE_RULES,
getCertificateReportDisplayTitle,
getDefaultCertificateExpiryDate,
isPublicCertificateReportPdf,
parseCertificateDate,
validateCertificateReportMetadata,
@ -55,8 +56,12 @@ describe('检测报告字段规则', () => {
})
})
test('到期时间独立必填,不自动计算,并拒绝早于检测日期的日期', () => {
for (const certificateExpiresOn of ['', undefined, '2028-02-30', '2028/09/23']) {
test('到期时间留空默认一年,手动填写时仍检查日期和先后顺序', () => {
for (const certificateExpiresOn of ['', undefined, null, ' ']) {
expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn }))
.toHaveProperty('value.certificateExpiresOn', new Date('2028-09-23T00:00:00.000Z'))
}
for (const certificateExpiresOn of ['2028-02-30', '2028/09/23', false, 0]) {
expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn })).toEqual({ error: '请选择有效的证书到期时间' })
}
expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn: '2027-09-22' }))
@ -64,6 +69,24 @@ describe('检测报告字段规则', () => {
expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn: '2027-09-23' }))
.toHaveProperty('value.certificateExpiresOn', new Date('2027-09-23T00:00:00.000Z'))
expect(validateCertificateReportMetadata(metadata)).not.toHaveProperty('value.certificateUsageInstructions')
expect(validateCertificateReportMetadata({ ...metadata, certificateExpiresOn: '2030-09-23' }))
.toHaveProperty('value.certificateExpiresOn', new Date('2030-09-23T00:00:00.000Z'))
})
test.each([
['2026-05-14', '2027-05-14'],
['2024-02-29', '2025-02-28'],
['2023-02-28', '2024-02-28'],
['2026-12-31', '2027-12-31'],
])('检测日期 %s 顺延一个日历年为 %s', (testedOn, expiresOn) => {
expect(getDefaultCertificateExpiryDate(testedOn)?.toISOString().slice(0, 10)).toBe(expiresOn)
expect(getDefaultCertificateExpiryDate(new Date(`${testedOn}T00:00:00Z`))?.toISOString().slice(0, 10)).toBe(expiresOn)
})
test('缺少检测日期时不推测到期时间', () => {
expect(getDefaultCertificateExpiryDate(null)).toBeNull()
expect(getDefaultCertificateExpiryDate('2026-02-30')).toBeNull()
expect(getDefaultCertificateExpiryDate(new Date('invalid'))).toBeNull()
})
test('平台分类只接受报告专用选项,整段预置说明保持为一个文本值', () => {

View File

@ -177,7 +177,7 @@ describe('EditMaterialPage', () => {
expect(mockBack).toHaveBeenCalledTimes(1)
})
test('检测报告编辑按成品和原材料切换字段,规则说明保留为一个输入框', async () => {
test.each([false, true])('检测报告编辑并保存日期,清空时恢复默认一年:%s', async (clearExpiry) => {
const user = userEvent.setup()
const report = {
...material,
@ -203,7 +203,7 @@ describe('EditMaterialPage', () => {
const kindSelect = await screen.findByLabelText('报告类别 *')
expect(screen.getByLabelText('SPU')).toHaveValue('SPU-001')
expect(screen.getByLabelText('证书检测日期 *')).toHaveTextContent('2027年9月23日')
expect(screen.getByLabelText('证书到期时间 *')).toHaveTextContent('2028年9月23日')
expect(screen.getByLabelText('证书到期时间(选填)')).toHaveTextContent('2028年9月23日')
expect(screen.queryByLabelText('证书使用说明 *')).not.toBeInTheDocument()
expect(screen.getByLabelText('检测报告使用规则说明 *')).toHaveValue('第一段\n第二段')
expect(screen.getByLabelText('平台分类')).toHaveValue('品牌')
@ -211,13 +211,14 @@ describe('EditMaterialPage', () => {
await user.selectOptions(kindSelect, '原材料')
expect(screen.queryByLabelText('SPU')).not.toBeInTheDocument()
expect(screen.getByLabelText('报告名称 / 检测对象 *')).toHaveValue('板材检测报告')
await user.click(screen.getByLabelText('证书到期时间 *'))
await user.click(screen.getByRole('button', { name: '2028年9月24日' }))
await user.click(screen.getByLabelText('证书到期时间(选填)'))
await user.click(screen.getByRole('button', { name: clearExpiry ? '清除' : '2028年9月24日' }))
if (clearExpiry) expect(screen.getByLabelText('证书到期时间(选填)')).toHaveTextContent('默认 2028-09-23')
fireEvent.submit(screen.getByLabelText('报告名称 / 检测对象 *').closest('form')!)
await waitFor(() => expect((global.fetch as jest.Mock).mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(true))
const [, init] = (global.fetch as jest.Mock).mock.calls.find(([, init]) => init?.method === 'PUT')!
expect(init.body.get('certificateTestedOn')).toBe('2027-09-23')
expect(init.body.get('certificateExpiresOn')).toBe('2028-09-24')
expect(init.body.get('certificateExpiresOn')).toBe(clearExpiry ? '' : '2028-09-24')
expect(init.body.get('reportUsageRules')).toBe('第一段\n第二段')
expect(init.body.has('certificateUsageInstructions')).toBe(false)
})

View File

@ -123,6 +123,22 @@ describe('material edit routes', () => {
return formData
}
test.each(['', '?intent=edit'])('报告详情推算未填到期时间,编辑保留空值以允许随检测日期计算 (%s)', async (query) => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1', userId: 'user-1', materialType: '证书-检测报告',
productModel: 'SPU-001', authorName: '测试作者', previewUrl: '',
categoryName: '通用', sceneName: '通用',
certificateTestedOn: new Date('2026-05-14T00:00:00Z'), certificateExpiresOn: null,
zipFileName: '', zipFilePath: '', zipFileSize: BigInt(0),
createdAt: new Date('2026-05-14T00:00:00Z'), assets: [],
})
const response = await getMaterialById(new NextRequest(`http://localhost/api/materials/material-1${query}`), { params: { id: 'material-1' } })
expect(response.status).toBe(200)
const body = await response.json()
expect(body.certificateExpiresOn).toBe(query ? null : '2027-05-14T00:00:00.000Z')
expect(body.canEdit).toBe(true)
})
function createPutRequest(url: string, formData: FormData) {
return new NextRequest(url, {
method: 'PUT',

View File

@ -259,6 +259,30 @@ describe('material-update shared rules', () => {
})
})
test('清空到期时间后保存,服务端根据检测日期计算一年', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '证书-检测报告',
certificateExpiresOn: new Date('2030-01-01T00:00:00Z'),
})
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({ ...baseMaterial, ...data }))
const result = await updateMaterialWithSharedRules({
materialId: 'material-1', operatorUserId: 'user-1', operatorRole: 'USER',
input: {
...reportMetadataInput,
certificateTestedOn: '2024-02-29',
certificateExpiresOn: '',
productModel: 'SPU-001', productCategoryName: '板材',
materialType: '证书-检测报告', authorName: '测试作者',
},
})
expect('error' in result).toBe(false)
expect(prisma.material.update).toHaveBeenCalledWith({ where: { id: 'material-1' }, data: expect.objectContaining({
certificateTestedOn: new Date('2024-02-29T00:00:00Z'),
certificateExpiresOn: new Date('2025-02-28T00:00:00Z'),
}) })
})
test('旧编辑调用未提交报告名称时应保留证书原值', async () => {
const existingCertificate = {
...baseMaterial,

View File

@ -125,6 +125,18 @@ describe('materials share security', () => {
})
})
test.each([null, new Date('2029-05-14T00:00:00.000Z')])('报告详情为未填到期日的记录计算一年,保留手动日期 (%s)', async (expiresOn) => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(buildMaterial({
materialType: '证书-检测报告',
certificateTestedOn: new Date('2026-05-14T00:00:00.000Z'),
certificateExpiresOn: expiresOn,
}))
const response = await GET(new NextRequest('https://sc.jiansun.vip/api/materials/share?id=material-1'))
expect(response.status).toBe(200)
const body = await response.json()
expect(body.certificateExpiresOn).toBe(expiresOn?.toISOString() || '2027-05-14T00:00:00.000Z')
})
test.each([null, { user: { id: 'admin-1' } }])('公开检测报告返回 PDF 和图片,保留源文件隔离 (%j)', async (session) => {
;(getServerSession as jest.Mock).mockResolvedValue(session)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ role: 'SYSTEM_ADMIN' })

View File

@ -73,12 +73,25 @@ export function parseCertificateDate(value: unknown): Date | null {
: parsed
}
export function getDefaultCertificateExpiryDate(testedOn: string | Date | null | undefined): Date | null {
const dateText = testedOn instanceof Date
? (Number.isNaN(testedOn.getTime()) ? '' : testedOn.toISOString().slice(0, 10))
: testedOn?.slice(0, 10)
const testedDate = parseCertificateDate(dateText)
if (!testedDate) return null
const expiresDate = new Date(testedDate)
expiresDate.setUTCFullYear(testedDate.getUTCFullYear() + 1)
// 闰年 2 月 29 日顺延到次年 2 月末,避免溢出到 3 月。
if (expiresDate.getUTCMonth() !== testedDate.getUTCMonth()) expiresDate.setUTCDate(0)
return expiresDate
}
export function validateCertificateReportMetadata(input: {
reportKind: unknown
productModel: unknown
reportTitle: unknown
certificateTestedOn: unknown
certificateExpiresOn: unknown
certificateExpiresOn?: unknown
certificateNumber: unknown
reportUsageRules: unknown
platformName: unknown
@ -105,7 +118,11 @@ export function validateCertificateReportMetadata(input: {
}
const certificateTestedOn = parseCertificateDate(input.certificateTestedOn)
if (!certificateTestedOn) return { error: '请选择有效的证书检测日期' }
const certificateExpiresOn = parseCertificateDate(input.certificateExpiresOn)
const expiryIsEmpty = input.certificateExpiresOn == null
|| (typeof input.certificateExpiresOn === 'string' && input.certificateExpiresOn.trim() === '')
const certificateExpiresOn = expiryIsEmpty
? getDefaultCertificateExpiryDate(certificateTestedOn)
: parseCertificateDate(input.certificateExpiresOn)
if (!certificateExpiresOn) return { error: '请选择有效的证书到期时间' }
if (certificateExpiresOn < certificateTestedOn) return { error: '证书到期时间不能早于检测日期' }
const certificateNumber = typeof input.certificateNumber === 'string' ? input.certificateNumber.trim() : ''