test(fleet): verify driver qualification lifecycle
This commit is contained in:
@@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol
|
||||
'manual-pda-ready-fixture', 'manual-pda-ready-verify',
|
||||
'manual-pda-batch-fixture', 'manual-pda-batch-verify',
|
||||
'manual-loading-complete-fixture', 'manual-loading-complete-verify',
|
||||
'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care']
|
||||
'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification']
|
||||
const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES])
|
||||
const A0_LOCATION_PAGE_SIZE = 200
|
||||
const A0_MAX_LOCATION_MUTATIONS = 8
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Admin 测试环境 HTTP 模块
|
||||
|
||||
`fleet-driver-qualification.mjs` 是司机证照专项可选模块,从 `admin-test-env-acceptance.mjs --modules fleet-driver-qualification --output <报告路径>` 执行,凭据由调用者显式 `node --env-file=<授权TEST环境文件>` 提供。经正式API创建两名本轮受控司机、上传非真实证照PNG,验证缺件/审核/打回/重提、30天内提醒、任务结束跨到期日禁止、非必需资质不阻断、关键资料及附件变化重审、旧版本拒绝、附件跨司机归属和历史下载。仅操作本轮司机及文件,不改排车/库存/财务、不执行SQL或部署;成功失败均按最新版本停用本轮司机,保留样本和报告 `createdResources.fleetDriverQualification`。失败重跑使用新样本,原失败证据不改写;该专项不代替整车实际发车链和浏览器验收。
|
||||
|
||||
`fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。
|
||||
|
||||
`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收、客户确认及预排接口生成两套独立本轮货物,再共用一个真实车次并各自转派送,停在尚未拣货。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { run } from './fleet-driver-qualification.mjs'
|
||||
const copy = value => structuredClone(value)
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
||||
function fixture(fault) {
|
||||
const rows = new Map(), history = new Map(), calls = [], files = new Map()
|
||||
const response = (data, code='200', http=200) => ({ http, body: { code, data: copy(data) }, requestId: 'test-request', traceId: 'test-trace' })
|
||||
function qualification(row, expectedEnd) {
|
||||
const now = Date.now(), expiry = Date.parse(row.details.licenseExpiresOn + 'T00:00:00Z')
|
||||
const expiringSoon = expiry >= now - 86400000 && expiry - now <= 30 * 86400000
|
||||
let reason = !row.active ? 'INACTIVE' : !row.licenseFileId ? 'MISSING_LICENSE' : row.reviewStatus === 'REJECTED' ? 'REJECTED'
|
||||
: row.reviewStatus !== 'APPROVED' ? 'PENDING_REVIEW' : !expectedEnd ? 'MISSING_TRIP_END'
|
||||
: Date.parse(expectedEnd) <= now ? 'INVALID_TRIP_END' : Date.parse(expectedEnd) >= expiry + 86400000 ? 'EXPIRES_DURING_TRIP' : 'ELIGIBLE'
|
||||
if (fault === 'allows expiry boundary' && reason === 'EXPIRES_DURING_TRIP') reason = 'ELIGIBLE'
|
||||
return { canDispatch: reason === 'ELIGIBLE', reason, expiringSoon: fault === 'missing warning' ? false : expiringSoon }
|
||||
}
|
||||
function present(row) { return { ...copy(row), qualification: qualification(row), licenseState: row.details.licenseExpiresOn < new Date().toISOString().slice(0,10) ? 'EXPIRED' : 'EXPIRING' } }
|
||||
function record(row, action) { history.get(row.id).unshift({ action, recordVersion: row.recordVersion, snapshot: present(row) }) }
|
||||
const context = { marker: 'CONTROLLED-DRIVER-TEST', createdResources: {},
|
||||
assert(_module, label, _response, condition) { assert.ok(condition, label) },
|
||||
apiSuccess(_module, label, value) { assert.equal(value.http, 200, label); assert.equal(value.body?.code, '200', label); return value.body.data },
|
||||
async request(method, raw, body) {
|
||||
calls.push({ method, raw, body: copy(body) }); const url = new URL(raw, 'https://fixture.invalid'), parts = url.pathname.split('/drivers'), suffix = parts[1]
|
||||
if (method === 'POST' && suffix === '') { const id = String(rows.size+1); const row = { id, driverNo: 'DR-'+id, details: copy(body), active: true, reviewStatus: 'PENDING', licenseFileId: null, recordVersion: 0 }; rows.set(id,row); history.set(id,[]); record(row,'CREATE'); return response(present(row)) }
|
||||
const [, id, action, fileId] = suffix.split('/'), row = rows.get(id); assert.ok(row)
|
||||
if (action === 'license') return fault === 'file outage' ? response(null, 'S0503', 503) : response(null, fault === 'cross-driver leak' ? '200' : 'A0404')
|
||||
if (method === 'GET' && !action) return response(present(row))
|
||||
if (action === 'qualification') return response(qualification(row, url.searchParams.get('expectedEnd')))
|
||||
if (action === 'history') return response({ records: history.get(id) })
|
||||
if (Number(body.recordVersion) !== row.recordVersion) return response(null, 'A0409')
|
||||
if (action === 'review') {
|
||||
if (body.decision === 'APPROVED' && (!row.licenseFileId || row.details.licenseExpiresOn < new Date().toISOString().slice(0,10))) return response(null, 'A0400')
|
||||
if (body.decision === 'REJECTED' && !body.reason) return response(null, 'A0400')
|
||||
row.reviewStatus = body.decision
|
||||
} else if (action === 'submit') row.reviewStatus = 'PENDING'
|
||||
else if (action === 'status') row.active = body.active
|
||||
else if (method === 'PUT') { if (body.details.licenseExpiresOn !== row.details.licenseExpiresOn && fault !== 'renewal skips review') row.reviewStatus='PENDING'; row.details=copy(body.details) }
|
||||
else throw new Error(`Unexpected request ${method} ${raw}`)
|
||||
row.recordVersion++; record(row, action === 'review' ? body.decision : action === 'submit' ? 'RESUBMIT' : action === 'status' ? 'DEACTIVATE' : 'UPDATE'); return response(present(row))
|
||||
},
|
||||
async requestMultipart(method, raw) { calls.push({ method, raw }); const id = raw.split('/drivers/')[1].split('/')[0], row = rows.get(id); row.licenseFileId=String(files.size+1);files.set(row.licenseFileId,id);row.reviewStatus='PENDING';row.recordVersion++;record(row,'UPLOAD_LICENSE');return response(present(row)) },
|
||||
async requestBinary(method, raw) { calls.push({ method, raw });return { http: 200, contentType: 'image/png', bytes: fault === 'corrupt image' ? Buffer.from('wrong') : PNG } },
|
||||
}
|
||||
return { context, rows, calls }
|
||||
}
|
||||
test('full driver fixture checks lifecycle and deactivates both owned drivers', async () => {
|
||||
const { context, rows } = fixture(); await run(context)
|
||||
assert.equal(context.createdResources.fleetDriverQualification.status, 'VERIFIED')
|
||||
assert.equal(rows.size, 2); assert.ok([...rows.values()].every(row=>!row.active))
|
||||
})
|
||||
for (const fault of ['allows expiry boundary', 'missing warning', 'cross-driver leak', 'file outage', 'renewal skips review', 'corrupt image']) {
|
||||
test(`driver verifier rejects ${fault} and still deactivates created drivers`, async () => {
|
||||
const { context, rows } = fixture(fault); await assert.rejects(run(context))
|
||||
assert.equal(context.createdResources.fleetDriverQualification.status, 'FAILED')
|
||||
assert.ok(rows.size>0); assert.ok([...rows.values()].every(row=>!row.active))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
const MODULE = 'fleet-driver-qualification'
|
||||
const BASE = '/api/platform/v1/last-leg/fleet/drivers'
|
||||
const OPTIONS = { scenario: 'driver-license-and-qualification', deploymentBlocker: true }
|
||||
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
|
||||
|
||||
/** Uses only new controlled TEST drivers, never real licence documents or existing driver assignments. */
|
||||
export async function run(context) {
|
||||
const proof = { status: 'CHECKING', drivers: [], requests: [], qualifications: [] }
|
||||
context.createdResources.fleetDriverQualification = proof
|
||||
const marker = `DQ-${randomUUID().slice(0, 12)}`
|
||||
const day = offset => new Date(Date.now() + offset * 86400000).toISOString().slice(0, 10)
|
||||
const expiry = day(15), nextDay = new Date(Date.parse(`${expiry}T00:00:00Z`) + 86400000).toISOString()
|
||||
const endBefore = new Date(Date.parse(nextDay) - 1).toISOString()
|
||||
const soonEnd = new Date(Date.now() + 86400000).toISOString()
|
||||
const details = { name: `${marker}受控司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS',
|
||||
licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: expiry,
|
||||
remark: `${context.marker} TEST image, not a real driving licence`,
|
||||
profile: { vehicleModels: 'Box Truck', languagesAndArea: 'TEST', medicalExpiresOn: day(-1), trainingExpiresOn: day(-1), insuranceExpiresOn: day(-1) } }
|
||||
function check(label, response, condition) {
|
||||
context.assert(MODULE, label, response, Boolean(condition), '', OPTIONS)
|
||||
if (!condition) throw new Error(label)
|
||||
}
|
||||
async function request(method, path, body) {
|
||||
const response = await context.request(method, path, body)
|
||||
proof.requests.push({ method, path, http: response.http, code: response.body?.code, requestId: response.requestId, traceId: response.traceId })
|
||||
return response
|
||||
}
|
||||
async function command(method, path, body, label) {
|
||||
const response = await request(method, path, body)
|
||||
const data = context.apiSuccess(MODULE, label, response, '', OPTIONS)
|
||||
if (response.http !== 200 || String(response.body?.code) !== '200' || data == null) throw new Error(label)
|
||||
return data
|
||||
}
|
||||
async function reject(method, path, body, code, label) {
|
||||
const response = await request(method, path, body)
|
||||
check(label, response, response.http === 200 && String(response.body?.code) === code)
|
||||
}
|
||||
async function qualification(id, end, reason, expiringSoon = undefined) {
|
||||
const result = await command('GET', `${BASE}/${id}/qualification?expectedEnd=${encodeURIComponent(end)}`, undefined, `读取司机资格:${reason}`)
|
||||
proof.qualifications.push({ driverId: String(id), expectedEnd: end, result })
|
||||
check(`司机资格准确判定${reason}`, null, result.reason === reason && result.canDispatch === (reason === 'ELIGIBLE')
|
||||
&& (expiringSoon === undefined || result.expiringSoon === expiringSoon))
|
||||
return result
|
||||
}
|
||||
async function download(id, fileId, label) {
|
||||
const result = await context.requestBinary('GET', `${BASE}/${id}/license/${fileId}`)
|
||||
check(label, result, result.http === 200 && result.contentType?.startsWith('image/png') && result.bytes instanceof Uint8Array && Buffer.from(result.bytes).equals(PNG))
|
||||
}
|
||||
let failure
|
||||
try {
|
||||
let driver = await command('POST', BASE, details, '建立本轮临近到期司机')
|
||||
const own = { id: String(driver.id), driverNo: driver.driverNo, initialDetails: details, files: [] }
|
||||
proof.drivers.push(own)
|
||||
const id = own.id, path = `${BASE}/${id}`
|
||||
await qualification(id, soonEnd, 'MISSING_LICENSE')
|
||||
await reject('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, 'A0400', '缺驾驶证附件不能审核通过')
|
||||
const uploaded = await context.requestMultipart('POST', `${path}/license?recordVersion=${driver.recordVersion}`, { name: `${marker}-TEST.png`, type: 'image/png', content: PNG })
|
||||
driver = context.apiSuccess(MODULE, '上传本轮受控驾驶证图片', uploaded, '', OPTIONS)
|
||||
check('上传图片保留待审而非直接通过', uploaded, driver?.licenseFileId && driver.reviewStatus === 'PENDING')
|
||||
const originalFile = String(driver.licenseFileId); own.files.push(originalFile)
|
||||
await qualification(id, soonEnd, 'PENDING_REVIEW')
|
||||
await reject('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'REJECTED' }, 'A0400', '打回不能缺少原因')
|
||||
driver = await command('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'REJECTED', reason: '核对本轮测试资料' }, '正式打回司机资料')
|
||||
await qualification(id, soonEnd, 'REJECTED')
|
||||
driver = await command('POST', `${path}/submit`, { recordVersion: driver.recordVersion }, '正式重提司机资料')
|
||||
const beforeApproval = driver.recordVersion
|
||||
driver = await command('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, '通过已补齐驾驶证的测试司机')
|
||||
await qualification(id, soonEnd, 'ELIGIBLE', true)
|
||||
await qualification(id, endBefore, 'ELIGIBLE', true)
|
||||
await qualification(id, nextDay, 'EXPIRES_DURING_TRIP', true)
|
||||
const missingEnd = await command('GET', path, undefined, '不填任务结束时间读取详情')
|
||||
check('没有预计结束时间不默认为可派', null, !missingEnd.qualification.canDispatch && missingEnd.qualification.reason === 'MISSING_TRIP_END')
|
||||
await qualification(id, new Date(Date.now() - 60000).toISOString(), 'INVALID_TRIP_END')
|
||||
await reject('PUT', path, { recordVersion: beforeApproval, details }, 'A0409', '旧司机版本不能覆盖审核结果')
|
||||
await download(id, originalFile, '本司机驾驶证下载字节一致')
|
||||
const other = await command('POST', BASE, { ...details, name: `${marker}隔离司机`, licenseNumber: `${marker}-B` }, '建立附件归属隔离司机')
|
||||
proof.drivers.push({ id: String(other.id), driverNo: other.driverNo, files: [] })
|
||||
await reject('GET', `${BASE}/${other.id}/license/${originalFile}`, undefined, 'A0404', '其他司机不能通过自身路径下载本司机证照')
|
||||
driver = await command('PUT', path, { recordVersion: driver.recordVersion, details: { ...details, remark: '仅补充备注' } }, '更正非资格信息')
|
||||
check('备注更正不清除已通过的驾驶资格', null, driver.reviewStatus === 'APPROVED')
|
||||
await qualification(id, soonEnd, 'ELIGIBLE', true)
|
||||
const renewed = { ...details, licenseExpiresOn: day(90) }
|
||||
driver = await command('PUT', path, { recordVersion: driver.recordVersion, details: renewed }, '更新证照有效期')
|
||||
check('关键证照变化回到待审', null, driver.reviewStatus === 'PENDING')
|
||||
await qualification(id, soonEnd, 'PENDING_REVIEW', false)
|
||||
driver = await command('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, '复核新证照有效期')
|
||||
await qualification(id, soonEnd, 'ELIGIBLE', false)
|
||||
const replacement = await context.requestMultipart('POST', `${path}/license?recordVersion=${driver.recordVersion}`, { name: `${marker}-REPLACEMENT-TEST.png`, type: 'image/png', content: PNG })
|
||||
driver = context.apiSuccess(MODULE, '替换本轮测试证照', replacement, '', OPTIONS)
|
||||
check('替换附件必须重新审核', replacement, driver?.reviewStatus === 'PENDING' && driver.licenseFileId)
|
||||
own.files.push(String(driver.licenseFileId))
|
||||
await download(id, originalFile, '原证照仍可按本司机历史归属下载')
|
||||
await download(id, driver.licenseFileId, '当前证照可下载')
|
||||
driver = await command('PUT', path, { recordVersion: driver.recordVersion, details: { ...details, licenseExpiresOn: day(-1) } }, '登记已过期测试证照')
|
||||
check('详情明确显示驾驶证已过期', null, driver.licenseState === 'EXPIRED' && !driver.qualification.canDispatch)
|
||||
await reject('POST', `${path}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED' }, 'A0400', '过期驾驶证不能通过审核')
|
||||
const history = await command('GET', `${path}/history?page=1&size=200`, undefined, '读取司机完整操作历史')
|
||||
check('历史保留上传审核打回重提和旧证照', null, ['CREATE', 'UPLOAD_LICENSE', 'REJECTED', 'RESUBMIT', 'APPROVED', 'UPDATE'].every(action => history.records.some(row => row.action === action))
|
||||
&& history.records.some(row => String(row.snapshot?.licenseFileId) === originalFile))
|
||||
proof.status = 'VERIFIED'
|
||||
} catch (error) { proof.status = 'FAILED'; proof.error = error instanceof Error ? error.message : String(error); failure = error }
|
||||
finally {
|
||||
for (const resource of proof.drivers) {
|
||||
try {
|
||||
const current = await command('GET', `${BASE}/${resource.id}`, undefined, '停用前读取本轮司机最新版本')
|
||||
const ended = current.active ? await command('PATCH', `${BASE}/${resource.id}/status`, { recordVersion: current.recordVersion, active: false, reason: '本轮测试结束,停用专用司机并保留证据' }, '停用本轮测试司机') : current
|
||||
check('测试司机已停用', null, ended.active === false); resource.cleanup = 'INACTIVE'
|
||||
await qualification(resource.id, soonEnd, 'INACTIVE')
|
||||
} catch (error) { resource.cleanup = 'FAILED'; proof.status = 'FAILED'; resource.cleanupError = error instanceof Error ? error.message : String(error); failure ??= error }
|
||||
}
|
||||
}
|
||||
if (failure) throw failure
|
||||
}
|
||||
Reference in New Issue
Block a user