Files
ShiErFeng/shierfeng-fastapi-frontend/scripts/verify-business-contract.mjs
T

478 lines
18 KiB
JavaScript

import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const frontendRoot = fileURLToPath(new URL('..', import.meta.url))
const expectedRequests = {
'src/api/master/strain.js': [
{ url: "'/master/strain/list'", method: 'get' },
{ url: "'/master/strain/' + strainId", method: 'get' },
{ url: "'/master/strain'", method: 'post' },
{ url: "'/master/strain'", method: 'put' },
{ url: "'/master/strain/' + strainIds", method: 'delete' }
],
'src/api/master/customer.js': [
{ url: "'/master/customer/list'", method: 'get' },
{ url: "'/master/customer/' + customerId", method: 'get' },
{ url: "'/master/customer'", method: 'post' },
{ url: "'/master/customer'", method: 'put' },
{ url: "'/master/customer/' + customerIds", method: 'delete' }
],
'src/api/master/site.js': [
{ url: "'/master/site/list'", method: 'get' },
{ url: "'/master/site/' + siteId", method: 'get' },
{ url: "'/master/site'", method: 'post' },
{ url: "'/master/site'", method: 'put' },
{ url: "'/master/site/' + siteIds", method: 'delete' },
{ url: "'/master/site/' + siteId + '/users'", method: 'get' },
{ url: "'/master/site/' + siteId + '/users'", method: 'put' }
],
'src/api/master/line.js': [
{ url: "'/master/line/list'", method: 'get' },
{ url: "'/master/line/' + lineId", method: 'get' },
{ url: "'/master/line'", method: 'post' },
{ url: "'/master/line'", method: 'put' },
{ url: "'/master/line/' + lineIds", method: 'delete' }
],
'src/api/master/warehouse.js': [
{ url: "'/master/warehouse/list'", method: 'get' },
{ url: "'/master/warehouse/' + warehouseId", method: 'get' },
{ url: "'/master/warehouse'", method: 'post' },
{ url: "'/master/warehouse'", method: 'put' },
{ url: "'/master/warehouse/' + warehouseIds", method: 'delete' },
{ url: "'/master/warehouse/' + warehouseId + '/users'", method: 'get' },
{ url: "'/master/warehouse/' + warehouseId + '/users'", method: 'put' }
],
'src/api/master/pallet.js': [
{ url: "'/master/pallet/list'", method: 'get' },
{ url: "'/master/pallet/' + palletId", method: 'get' },
{ url: "'/master/pallet/' + palletId + '/qrcode'", method: 'get' },
{ url: "'/master/pallet'", method: 'post' },
{ url: "'/master/pallet'", method: 'put' },
{ url: "'/master/pallet/' + palletIds", method: 'delete' }
],
'src/api/business/inbound.js': [
{ url: "'/business/inbound/list'", method: 'get' },
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
{ url: "'/business/inbound/scan/' + qrToken", method: 'get' },
{ url: "'/business/inbound/' + inboundId", method: 'get' },
{ url: "'/business/inbound'", method: 'post' },
{ url: "'/business/inbound'", method: 'put' },
{ url: "'/business/inbound/' + inboundIds", method: 'delete' },
{ url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' }
],
'src/api/business/filling.js': [
{ url: "'/business/filling/list'", method: 'get' },
{ url: "'/business/filling/strain-options'", method: 'get' },
{ url: "'/business/filling/scan/' + qrToken", method: 'get' },
{ url: "'/business/filling/' + fillingId", method: 'get' },
{ url: "'/business/filling'", method: 'post' }
],
'src/api/business/shipment.js': [
{ url: "'/business/shipment/list'", method: 'get' },
{ url: "'/business/shipment/warehouse-options'", method: 'get' },
{ url: "'/business/shipment/site-options'", method: 'get' },
{ url: "'/business/shipment/scan/' + qrToken", method: 'get' },
{ url: "'/business/shipment/' + shipmentId", method: 'get' },
{ url: "'/business/shipment'", method: 'post' },
{ url: "'/business/shipment'", method: 'put' },
{ url: "'/business/shipment/' + shipmentIds", method: 'delete' },
{ url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' }
],
'src/api/business/usage.js': [
{ url: "'/business/usage/list'", method: 'get' },
{ url: "'/business/usage/line-options'", method: 'get' },
{ url: "'/business/usage/scan/' + qrToken", method: 'get' },
{ url: "'/business/usage/' + usageId", method: 'get' },
{ url: "'/business/usage'", method: 'post' }
],
'src/api/business/return.js': [
{ url: "'/business/return/list'", method: 'get' },
{ url: "'/business/return/warehouse-options'", method: 'get' },
{ url: "'/business/return/scan/' + qrToken", method: 'get' },
{ url: "'/business/return/request'", method: 'post' },
{ url: "'/business/return/' + returnId", method: 'get' },
{ url: "'/business/return/' + returnId + '/confirm'", method: 'put' }
],
'src/api/report/palletInventory.js': [
{ url: "'/report/pallet-inventory/list'", method: 'get' }
],
'src/api/report/filling.js': [
{ url: "'/report/filling/list'", method: 'get' }
],
'src/api/report/shipment.js': [
{ url: "'/report/shipment/list'", method: 'get' }
],
'src/api/report/consumption.js': [
{ url: "'/report/consumption/list'", method: 'get' }
],
'src/api/report/return.js': [
{ url: "'/report/return/list'", method: 'get' }
],
'src/api/report/palletTrace.js': [
{ url: "'/report/pallet-trace/list'", method: 'get' }
]
}
const expectedReportExports = {
'src/api/report/palletInventory.js': ['listPalletInventory'],
'src/api/report/filling.js': ['listFillingReport'],
'src/api/report/shipment.js': ['listShipmentReport'],
'src/api/report/consumption.js': ['listConsumptionReport'],
'src/api/report/return.js': ['listReturnReport'],
'src/api/report/palletTrace.js': ['listPalletTrace']
}
const delimiterPairs = { '(': ')', '[': ']', '{': '}' }
function sanitizeSource(source) {
const code = source.split('')
const structure = source.split('')
for (let index = 0; index < source.length;) {
const quote = source[index]
if (quote === '\'' || quote === '"' || quote === '`') {
let literalEnd = index + 1
while (literalEnd < source.length && source[literalEnd] !== quote) {
if (source[literalEnd] === '\\') {
literalEnd += 1
} else if (quote === '`' && source[literalEnd] === '$' && source[literalEnd + 1] === '{') {
throw new Error('契约解析器不支持包含插值的模板字符串')
}
literalEnd += 1
}
if (literalEnd >= source.length) {
throw new Error('契约源码包含未闭合字符串')
}
structure.fill('\0', index, literalEnd + 1)
index = literalEnd + 1
continue
}
const lineComment = source[index] === '/' && source[index + 1] === '/'
const blockComment = source[index] === '/' && source[index + 1] === '*'
if (!lineComment && !blockComment) {
index += 1
continue
}
let commentEnd = lineComment ? source.indexOf('\n', index + 2) : source.indexOf('*/', index + 2)
if (commentEnd === -1 && blockComment) {
throw new Error('契约源码包含未闭合注释')
}
commentEnd = commentEnd === -1 ? source.length : commentEnd + (blockComment ? 2 : 0)
while (index < commentEnd) {
if (source[index] !== '\n' && source[index] !== '\r') {
code[index] = ' '
structure[index] = ' '
}
index += 1
}
}
return { code: code.join(''), structure: structure.join('') }
}
function findClosingDelimiter(structure, startIndex) {
const open = structure[startIndex]
const close = delimiterPairs[open]
let depth = 0
for (let index = startIndex; index < structure.length; index += 1) {
if (structure[index] === open) {
depth += 1
} else if (structure[index] === close && --depth === 0) {
return index
}
}
throw new Error(`契约源码包含未闭合的 ${open}${close}`)
}
function splitTopLevelRanges(structure, separator) {
const ranges = []
const stack = []
let rangeStart = 0
for (let index = 0; index < structure.length; index += 1) {
const character = structure[index]
if (Object.hasOwn(delimiterPairs, character)) {
stack.push(character)
} else if (Object.values(delimiterPairs).includes(character)) {
const open = stack.pop()
if (delimiterPairs[open] !== character) {
throw new Error('request 对象属性括号不匹配')
}
} else if (character === separator && stack.length === 0) {
ranges.push([rangeStart, index])
rangeStart = index + 1
}
}
if (stack.length) {
throw new Error('request 对象属性括号不匹配')
}
ranges.push([rangeStart, structure.length])
return ranges
}
function normalizeUrlExpression(code, structure) {
let normalized = ''
for (let index = 0; index < code.length; index += 1) {
if (structure[index] === '\0') {
normalized += code[index]
} else if (/\s/.test(code[index])) {
continue
} else if (code[index] === '+') {
normalized = normalized.trimEnd() + ' + '
} else {
normalized += code[index]
}
}
return normalized.trim()
}
function parseRequestArgument(argument, callIndex) {
let objectStart = 0
while (/\s/.test(argument.structure[objectStart])) {
objectStart += 1
}
if (argument.structure[objectStart] !== '{') {
throw new Error(`第 ${callIndex} 个 request 调用必须使用对象字面量`)
}
const objectEnd = findClosingDelimiter(argument.structure, objectStart)
if (argument.structure.slice(objectEnd + 1).trim()) {
throw new Error(`第 ${callIndex} 个 request 调用必须只传入一个对象字面量`)
}
const bodyCode = argument.code.slice(objectStart + 1, objectEnd)
const bodyStructure = argument.structure.slice(objectStart + 1, objectEnd)
const values = { url: [], method: [] }
for (const [start, end] of splitTopLevelRanges(bodyStructure, ',')) {
const propertyCode = bodyCode.slice(start, end)
const propertyStructure = bodyStructure.slice(start, end)
if (!propertyCode.trim()) {
continue
}
const propertyParts = splitTopLevelRanges(propertyStructure, ':')
const key = propertyCode.slice(...propertyParts[0]).trim()
if (propertyParts.length !== 2 || !/^[A-Za-z_$][\w$]*$/.test(key)) {
throw new Error(`第 ${callIndex} 个 request 对象只支持静态键值属性`)
}
if (key === 'url' || key === 'method') {
const [valueStart, valueEnd] = propertyParts[1]
values[key].push({
code: propertyCode.slice(valueStart, valueEnd),
structure: propertyStructure.slice(valueStart, valueEnd)
})
}
}
if (values.url.length !== 1 || values.method.length !== 1) {
throw new Error(`第 ${callIndex} 个 request 对象必须包含唯一 url 和 method`)
}
const methodMatch = /^\s*(['"])([a-z]+)\1\s*$/.exec(values.method[0].code)
if (!methodMatch) {
throw new Error(`第 ${callIndex} 个 request method 必须是静态小写字符串`)
}
return {
url: normalizeUrlExpression(values.url[0].code, values.url[0].structure),
method: methodMatch[2]
}
}
function extractRequests(source) {
const sanitized = sanitizeSource(source)
const requestPattern = /(?<![\w$])request\s*\(/g
const requests = []
let match
while ((match = requestPattern.exec(sanitized.structure)) !== null) {
const openParenthesis = match.index + match[0].lastIndexOf('(')
const closeParenthesis = findClosingDelimiter(sanitized.structure, openParenthesis)
requests.push(parseRequestArgument({
code: sanitized.code.slice(openParenthesis + 1, closeParenthesis),
structure: sanitized.structure.slice(openParenthesis + 1, closeParenthesis)
}, requests.length + 1))
}
return requests
}
function hasExpectedRequest(expectedRequest) {
return Object.values(expectedRequests).some((requests) =>
requests.some((request) =>
request.url === expectedRequest.url && request.method === expectedRequest.method
)
)
}
test('契约解析器支持 url 与 method 换序及空白格式变化', () => {
const source = `
export function sample(query) {
return request(
{
method : 'get',
params: query,
url :
'/sample/' + query.id
}
)
}
`
assert.deepEqual(extractRequests(source), [
{ url: "'/sample/' + query.id", method: 'get' }
])
})
test('契约解析器拒绝无法静态核对的 request 调用', () => {
assert.throws(
() => extractRequests('export function sample(config) { return request(config) }'),
/request 调用必须使用对象字面量/
)
assert.throws(
() => extractRequests("return request({ url: '/sample', url: '/other', method: 'get' })"),
/唯一 url 和 method/
)
assert.throws(
() => extractRequests("return request({ url: '/sample' })"),
/唯一 url 和 method/
)
})
test('契约解析器忽略注释和字符串中的伪 request 调用', () => {
const source = `
// return request({ url: '/commented', method: 'delete' })
const example = "request(config)"
/*
request({ method: 'post', url: '/also-commented' })
*/
export function sample() {
return request({
url: '/sample',
/* 属性间注释不应干扰解析 */
method: 'get'
})
}
`
assert.deepEqual(extractRequests(source), [
{ url: "'/sample'", method: 'get' }
])
})
test('扫码值会去除首尾空白,空白二维码会被拒绝', async () => {
const { normalizeScanValue } = await import('../src/utils/palletScan.js')
assert.equal(normalizeScanValue(' qr-12345678\n'), 'qr-12345678')
assert.throws(() => normalizeScanValue(' \n\t '), /二维码不能为空/)
})
test('重复扫码仅在相同值和时间窗口内成立', async () => {
const { isDuplicateScan } = await import('../src/utils/palletScan.js')
assert.equal(isDuplicateScan('qr-a', 'qr-a', 1200, 1000, 500), true)
assert.equal(isDuplicateScan('qr-a', 'qr-b', 1200, 1000, 500), false)
})
test('幂等键由 Web Crypto randomUUID 生成且不降级为伪随机值', async () => {
const palletScanPath = path.join(frontendRoot, 'src/utils/palletScan.js')
const source = await readFile(palletScanPath, 'utf8')
const { createIdempotencyKey } = await import('../src/utils/palletScan.js')
assert.match(source, /globalThis\.crypto\.randomUUID\(\)/)
assert.doesNotMatch(source, /Date\.now|Math\.random|new Date/)
assert.match(createIdempotencyKey(), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
})
test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => {
assert.equal(Object.keys(expectedRequests).length, 17)
assert.equal(Object.values(expectedRequests).flat().length, 74)
for (const [relativePath, expected] of Object.entries(expectedRequests)) {
await t.test(relativePath, async () => {
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
const actual = extractRequests(source)
assert.deepEqual(actual, expected)
})
}
})
test('关键扫码、选项、确认与二维码契约被显式覆盖', () => {
const requiredRequests = [
{ url: "'/business/inbound/scan/' + qrToken", method: 'get' },
{ url: "'/business/filling/scan/' + qrToken", method: 'get' },
{ url: "'/business/shipment/scan/' + qrToken", method: 'get' },
{ url: "'/business/usage/scan/' + qrToken", method: 'get' },
{ url: "'/business/return/scan/' + qrToken", method: 'get' },
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
{ url: "'/business/filling/strain-options'", method: 'get' },
{ url: "'/business/shipment/warehouse-options'", method: 'get' },
{ url: "'/business/shipment/site-options'", method: 'get' },
{ url: "'/business/usage/line-options'", method: 'get' },
{ url: "'/business/return/warehouse-options'", method: 'get' },
{ url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' },
{ url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' },
{ url: "'/business/return/' + returnId + '/confirm'", method: 'put' },
{ url: "'/business/return/request'", method: 'post' },
{ url: "'/master/pallet/' + palletId + '/qrcode'", method: 'get' }
]
for (const requiredRequest of requiredRequests) {
assert.equal(hasExpectedRequest(requiredRequest), true, JSON.stringify(requiredRequest))
}
})
test('六个报表 API 只提供 GET /list,不提供导出接口', async () => {
const reportEntries = Object.entries(expectedRequests).filter(([relativePath]) =>
relativePath.startsWith('src/api/report/')
)
assert.equal(reportEntries.length, 6)
for (const [relativePath, expected] of reportEntries) {
assert.equal(expected.length, 1)
assert.equal(expected[0].method, 'get')
assert.match(expected[0].url, /^'\/report\/.+\/list'$/)
const source = await readFile(path.join(frontendRoot, relativePath), 'utf8')
const exportedFunctionNames = [...source.matchAll(/\bexport\s+function\s+(\w+)/g)].map((match) => match[1])
assert.doesNotMatch(expected[0].url, /export/i)
assert.deepEqual(exportedFunctionNames, expectedReportExports[relativePath])
}
})
test('扫码组件声明交互契约并保持触控尺寸', async () => {
const source = await readFile(path.join(frontendRoot, 'src/components/PalletScanInput/index.vue'), 'utf8')
for (const propName of ['disabled', 'loading', 'minLength', 'duplicateWindowMs']) {
assert.match(source, new RegExp(`\\b${propName}\\b`))
}
for (const eventName of ['scan', 'invalid', 'duplicate']) {
assert.match(source, new RegExp(`['"]${eventName}['"]`))
}
assert.match(source, /@keyup\.enter="handleSubmit"/)
assert.match(source, /normalizeScanValue\(scanValue\.value\)/)
assert.match(source, /isDuplicateScan\(/)
assert.match(source, /emit\(['"]invalid['"]/)
assert.match(source, /emit\(['"]duplicate['"]/)
assert.match(source, /emit\(['"]scan['"]/)
assert.match(source, /nextTick\(/)
assert.match(source, /inputRef\.value\?\.focus\(\)/)
assert.match(source, /if \(!props\.disabled && !props\.loading\)/)
assert.match(source, /focusInput\(true\)/)
assert.match(source, /inputRef\.value\?\.select\(\)/)
assert.match(source, /nextTick\([\s\S]*?focus\(\)[\s\S]*?select\(\)/)
assert.match(source, /watch\(\s*\(\) => \[props\.loading, props\.disabled\]/)
assert.match(source, /if \(!loading && !disabled\)/)
assert.match(source, /min-height:\s*48px/)
assert.match(source, /width:\s*100%/)
assert.match(source, /is-loading/)
assert.match(source, /is-disabled/)
assert.match(source, /is-error/)
assert.match(source, /focus-within/)
assert.doesNotMatch(source, /BarcodeDetector|camera|getUserMedia/i)
})