191 lines
11 KiB
JavaScript
191 lines
11 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import fs from 'node:fs'
|
|
import ts from 'typescript'
|
|
import { createRenderer, h, onMounted, ref, nextTick } from 'vue'
|
|
import { createRouter, createMemoryHistory, RouterView, useRoute } from 'vue-router'
|
|
import { clearSessionState } from '../src/auth/sessionState.ts'
|
|
import { createRequestScope } from '../src/utils/requestScope.ts'
|
|
import { parseEquipmentImportRows } from '../src/utils/equipmentImport.ts'
|
|
import * as XLSX from 'xlsx'
|
|
|
|
test('the real route guard reaches login once after an expired session, without dashboard bounce', async () => {
|
|
const source = fs.readFileSync(new URL('../src/router/index.ts', import.meta.url), 'utf8')
|
|
const guardSource = source.slice(source.indexOf('router.beforeEach(async ') + 18, source.lastIndexOf('\n})') + 2)
|
|
const executable = ts.transpileModule(`const guard = ${guardSource}`.replace('import.meta.env.VITE_AUTH_MODE', "'local'"), { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText
|
|
const state = {
|
|
user: { id: 4 }, token: 'session', isAuthenticated: true,
|
|
refreshUser: async () => { throw new Error('401 session expired') },
|
|
clearSession: () => clearSessionState(state, { removeItem: () => {} })
|
|
}
|
|
const guard = new Function('useAuthStore', 'resolveAuthMode', 'keycloak', `${executable}; return guard;`)(() => state, () => 'local', {})
|
|
let nextTarget
|
|
await guard({ path: '/equipment', meta: { requiresAuth: true } }, {}, (target) => { nextTarget = target })
|
|
assert.equal(nextTarget, '/login')
|
|
nextTarget = 'not-called'
|
|
await guard({ path: '/login', meta: { requiresAuth: false } }, {}, (target) => { nextTarget = target })
|
|
assert.equal(nextTarget, undefined)
|
|
assert.equal(state.isAuthenticated, false)
|
|
})
|
|
|
|
test('changing detail IDs remounts the detail view with the key used by MainLayout', async () => {
|
|
const layout = fs.readFileSync(new URL('../src/layouts/MainLayout.vue', import.meta.url), 'utf8')
|
|
assert.match(layout, /<router-view\s+:key="route\.path"/)
|
|
const renderer = createRenderer({
|
|
createElement: (type) => ({ type, children: [] }), createText: (text) => ({ text }), createComment: (text) => ({ text }),
|
|
setText: (node, text) => { node.text = text }, setElementText: (node, text) => { node.text = text },
|
|
parentNode: (node) => node.parent || null, nextSibling: () => null, patchProp: () => {},
|
|
insert: (node, parent) => { parent.children.push(node); node.parent = parent },
|
|
remove: (node) => { if (node.parent) node.parent.children = node.parent.children.filter((item) => item !== node) }
|
|
})
|
|
let currentDetail
|
|
let mountedCount = 0
|
|
const Detail = { setup() {
|
|
const route = useRoute()
|
|
const displayedId = ref(null)
|
|
onMounted(() => { mountedCount += 1; displayedId.value = Number(route.params.id) })
|
|
currentDetail = { displayedId, targetId: () => Number(route.params.id) }
|
|
return () => h('div', String(displayedId.value))
|
|
} }
|
|
const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/equipment/edit/:id', component: Detail }] })
|
|
await router.push('/equipment/edit/1')
|
|
const app = renderer.createApp({ setup() { const route = useRoute(); return () => h(RouterView, { key: route.path }) } })
|
|
app.use(router)
|
|
app.mount({ children: [] })
|
|
await nextTick()
|
|
await router.push('/equipment/edit/2')
|
|
await nextTick()
|
|
assert.equal(currentDetail.displayedId.value, 2)
|
|
assert.equal(currentDetail.targetId(), 2)
|
|
assert.equal(mountedCount, 2)
|
|
app.unmount()
|
|
})
|
|
|
|
test('print resolution requires a checked outcome and submits the originally inspected claim token', async () => {
|
|
const source = fs.readFileSync(new URL('../src/views/settings/components/PrintJobManagement.vue', import.meta.url), 'utf8')
|
|
const start = source.indexOf('const submitResolution = async () =>')
|
|
const end = source.indexOf('\nonMounted(', start)
|
|
const compiled = ts.transpileModule(source.slice(start, end), { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText
|
|
const canResolve = { value: true }
|
|
const resolutionJob = { value: { id: 83, claim_token: 'inspected-claim' } }
|
|
const resolutionOutcome = { value: 'not_printed' }
|
|
const resolutionVerified = { value: false }
|
|
const busyJobId = { value: null }
|
|
const submissions = []
|
|
let refreshes = 0
|
|
const submit = new Function('canResolve', 'resolutionJob', 'resolutionOutcome', 'resolutionVerified', 'busyJobId', 'resolvePrintJob', 'fetchJobs', 'alert', `${compiled}; return submitResolution;`)(
|
|
canResolve, resolutionJob, resolutionOutcome, resolutionVerified, busyJobId,
|
|
async (id, payload) => { submissions.push({ id, payload }) }, async () => { refreshes += 1 }, () => {}
|
|
)
|
|
await submit()
|
|
assert.equal(submissions.length, 0)
|
|
resolutionVerified.value = true
|
|
canResolve.value = false
|
|
await submit()
|
|
assert.equal(submissions.length, 0)
|
|
canResolve.value = true
|
|
await submit()
|
|
assert.deepEqual(submissions, [{ id: 83, payload: { confirmed: true, outcome: 'not_printed', expected_claim_token: 'inspected-claim' } }])
|
|
assert.equal(resolutionJob.value, null)
|
|
assert.equal(busyJobId.value, null)
|
|
assert.equal(refreshes, 1)
|
|
})
|
|
|
|
const extractListFunction = (startMarker, endMarker, bindings) => {
|
|
const source = fs.readFileSync(new URL('../src/views/equipment/EquipmentList.vue', import.meta.url), 'utf8')
|
|
const start = source.indexOf(`const ${startMarker}`)
|
|
const end = source.indexOf(`const ${endMarker}`, start)
|
|
const compiled = ts.transpileModule(source.slice(start, end), { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText
|
|
return new Function(...Object.keys(bindings), `${compiled}; return ${startMarker};`)(...Object.values(bindings))
|
|
}
|
|
|
|
for (const delayedStage of ['detail', 'records', 'fallback']) {
|
|
test(`opening asset B supersedes a slow asset A return lookup at ${delayedStage}`, async () => {
|
|
const returnDialogScope = createRequestScope()
|
|
const selectedIds = { value: [1] }
|
|
const returnAssetInfo = { value: null }
|
|
const returnMode = { value: 'batch' }
|
|
const returnAnomalyInfo = { value: null }
|
|
const returnSelectedRecordId = { value: null }
|
|
const returnRecordKeyword = { value: '' }
|
|
const showBatchReturnModal = { value: false }
|
|
let finishA
|
|
let notifyReached
|
|
const reached = new Promise((resolve) => { notifyReached = resolve })
|
|
const delayed = () => { notifyReached(); return new Promise((resolve) => { finishA = resolve }) }
|
|
const detail = (id) => ({ data: { id, equipment_code: id === 1 ? 'A' : 'B', equipment_name: id === 1 ? 'Asset A' : 'Asset B' } })
|
|
const records = (code) => ({ data: { can_return: true, equipment_code: code, equipment_name: code, borrow_records: [{ id: code === 'A' ? 101 : 202 }] } })
|
|
const bindings = {
|
|
returnDialogScope, selectedIds, returnAssetInfo, returnMode, returnAnomalyInfo,
|
|
returnSelectedRecordId, returnRecordKeyword, showBatchReturnModal,
|
|
returnSubmitting: { value: false }, borrowSubmitting: { value: false },
|
|
clearSingleActionContext: () => {},
|
|
resetReturnForm: () => { returnDialogScope.begin(); returnAssetInfo.value = null; returnSelectedRecordId.value = null },
|
|
canQuickReturnInline: () => false,
|
|
buildManualReturnRecordDisplay: (record) => String(record.id),
|
|
alert: (message) => { throw new Error(`unexpected alert: ${message}`) },
|
|
getEquipmentDetail: async (id) => id === 1 && delayedStage === 'detail' ? delayed() : detail(id),
|
|
getBorrowRecordByCode: async (code) => {
|
|
if (code === 'A' && delayedStage === 'records') return delayed()
|
|
if (code === 'A' && delayedStage === 'fallback') return { data: { ...records('A').data, can_return: false } }
|
|
return records(code)
|
|
},
|
|
searchReturnAssets: async () => delayed()
|
|
}
|
|
const open = extractListFunction('openBatchReturnModalInternal', 'openBatchReturnModal =', bindings)
|
|
const pendingA = open()
|
|
await reached
|
|
selectedIds.value = [2]
|
|
await open()
|
|
assert.equal(returnAssetInfo.value.equipment_code, 'B')
|
|
assert.equal(returnSelectedRecordId.value, 202)
|
|
finishA(delayedStage === 'detail' ? detail(1) : delayedStage === 'records' ? records('A') : { data: [{ equipment_id: 1, can_return: false }] })
|
|
await pendingA
|
|
assert.equal(returnAssetInfo.value.equipment_code, 'B')
|
|
assert.equal(returnSelectedRecordId.value, 202)
|
|
assert.equal(returnMode.value, 'record-select')
|
|
assert.equal(returnAnomalyInfo.value, null)
|
|
})
|
|
}
|
|
|
|
test('successful single action preserves prior cross-page selections and removes only its successful asset', () => {
|
|
const selectedIds = { value: [30] }
|
|
const singleActionContext = { value: { previousSelectedIds: [1, 21, 30] } }
|
|
const selectionEquipment = { value: new Map([[1, { equipment_code: 'A' }], [21, { equipment_code: 'B' }], [30, { equipment_code: 'Z' }]]) }
|
|
const finish = extractListFunction('finishSuccessfulAssetAction', 'restoreSelectionAfterSingleActionCancel', {
|
|
selectedIds, singleActionContext, selectionEquipment,
|
|
clearSingleActionContext: () => { singleActionContext.value = null }
|
|
})
|
|
finish(new Set(['Z']))
|
|
assert.deepEqual(selectedIds.value, [1, 21])
|
|
assert.equal(singleActionContext.value, null)
|
|
})
|
|
|
|
test('a workbook starting at A3 reports its data row as actual Excel row 4', () => {
|
|
const sheet = {
|
|
A3: { t: 's', v: 'name' }, B3: { t: 's', v: 'type' },
|
|
A4: { t: 's', v: 'asset' }, B4: { t: 's', v: 'computer' }, G4: { t: 'n', v: 1 },
|
|
'!ref': 'A3:G4'
|
|
}
|
|
const workbook = XLSX.utils.book_new()
|
|
XLSX.utils.book_append_sheet(workbook, sheet, 'assets')
|
|
const reread = XLSX.read(XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }), { type: 'buffer' })
|
|
const actual = reread.Sheets.assets
|
|
const rows = XLSX.utils.sheet_to_json(actual, { header: 1, blankrows: true })
|
|
assert.equal(parseEquipmentImportRows(rows, XLSX.utils.decode_range(actual['!ref']).s.r)[0].source_row, 4)
|
|
})
|
|
|
|
test('sending more than 100 remote labels refuses before generating or creating any real task', async () => {
|
|
const source = fs.readFileSync(new URL('../src/components/BarcodePrint.vue', import.meta.url), 'utf8')
|
|
const start = source.indexOf('const handleRemotePrint = async () =>')
|
|
const end = source.indexOf('const handleCancelRemoteJob', start)
|
|
const compiled = ts.transpileModule(source.slice(start, end), { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText
|
|
const notices = []
|
|
const remotePrint = new Function('props', 'equipmentList', 'alert', `${compiled}; return handleRemotePrint;`)(
|
|
{ equipmentIds: Array.from({ length: 101 }, (_, index) => index + 1) }, { value: [] }, (message) => { notices.push(message) }
|
|
)
|
|
await remotePrint()
|
|
assert.equal(notices.length, 1)
|
|
assert.match(notices[0], /100/)
|
|
})
|