158 lines
6.7 KiB
JavaScript
158 lines
6.7 KiB
JavaScript
import test from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import fs from 'node:fs/promises'
|
||
import path from 'node:path'
|
||
import os from 'node:os'
|
||
import { createAgentRuntime } from '../src/services/agentRuntime.js'
|
||
import { createPrintJournal } from '../src/services/printJournal.js'
|
||
|
||
const config = { serverUrl: 'https://asset.example.com', terminalId: 1, deviceToken: 'test-only', selectedPrinterName: 'Test printer' }
|
||
const claimedJob = { id: 7, job_type: 'asset_label', claim_token: 'attempt-one' }
|
||
const pendingResult = (phase) => ({ jobId: 7, claimToken: 'attempt-one', phase, serverUrl: config.serverUrl, terminalId: config.terminalId })
|
||
const memoryJournal = (initial = null) => ({
|
||
entry: initial,
|
||
async load() { return this.entry },
|
||
async save(entry) { this.entry = structuredClone(entry) },
|
||
async clear() { this.entry = null }
|
||
})
|
||
|
||
async function fixture({ journal = memoryJournal(), dispatch = async () => ({ ok: true }), report, readState } = {}) {
|
||
let prints = 0
|
||
let claims = 0
|
||
const reports = []
|
||
const runtime = createAgentRuntime({
|
||
journal,
|
||
store: { async load() { return { ...config } } },
|
||
clientFactory: () => ({
|
||
async claimNextJob(payload) { claims += 1; assert.equal(payload.protocolVersion, 2); return { job: claimedJob } },
|
||
async reportJobStatus(id, payload) {
|
||
reports.push(payload)
|
||
assert.equal(payload.claimToken, 'attempt-one')
|
||
await report?.(id, payload)
|
||
},
|
||
getJobState: readState
|
||
}),
|
||
jobProcessor: { async process() { prints += 1; return dispatch() } },
|
||
getPrinters: async () => [],
|
||
setIntervalImpl: () => 1,
|
||
clearIntervalImpl: () => {}
|
||
})
|
||
await runtime.loadConfig()
|
||
return { runtime, journal, reports, prints: () => prints, claims: () => claims }
|
||
}
|
||
|
||
test('打印成功回写断网后只补结果,不能再出纸或报failed', async () => {
|
||
let completionCalls = 0
|
||
const f = await fixture({ report: async (_, payload) => {
|
||
if (payload.status === 'completed' && ++completionCalls === 1) throw new Error('network disconnected')
|
||
} })
|
||
await assert.rejects(f.runtime.pollOnce(), /network disconnected/)
|
||
assert.equal(f.journal.entry.phase, 'completed_pending_ack')
|
||
await f.runtime.pollOnce()
|
||
assert.equal(f.prints(), 1)
|
||
assert.equal(f.claims(), 1)
|
||
assert.deepEqual(f.reports.map(r => r.status), ['printing', 'completed', 'completed'])
|
||
assert.equal(f.journal.entry, null)
|
||
})
|
||
|
||
test('重启已完成任务只补completed;dispatching任务上报待核实', async () => {
|
||
for (const phase of ['completed_pending_ack', 'dispatching', 'claimed']) {
|
||
const f = await fixture({ journal: memoryJournal(pendingResult(phase)) })
|
||
await f.runtime.pollOnce()
|
||
assert.equal(f.prints(), 0)
|
||
assert.equal(f.claims(), 0)
|
||
assert.equal(f.reports[0].status, phase === 'completed_pending_ack' ? 'completed' : 'result_unknown')
|
||
}
|
||
})
|
||
|
||
test('部分出纸后执行异常进入待核实,不自动重派', async () => {
|
||
const f = await fixture({ dispatch: async () => { throw new Error('second label failed') } })
|
||
await f.runtime.pollOnce()
|
||
assert.equal(f.prints(), 1)
|
||
assert.deepEqual(f.reports.map(r => r.status), ['printing', 'result_unknown'])
|
||
})
|
||
|
||
test('恢复记录落盘失败时禁止出纸', async () => {
|
||
const journal = memoryJournal()
|
||
journal.save = async () => { throw new Error('disk full') }
|
||
const f = await fixture({ journal })
|
||
await assert.rejects(f.runtime.pollOnce(), /disk full/)
|
||
assert.equal(f.prints(), 0)
|
||
assert.equal(f.reports.length, 0)
|
||
})
|
||
|
||
test('领取或处理未结束时重复poll不再领取', async () => {
|
||
let release
|
||
const held = new Promise(resolve => { release = resolve })
|
||
const f = await fixture({ dispatch: async () => { await held; return { ok: true } } })
|
||
const first = f.runtime.pollOnce()
|
||
await new Promise(resolve => setImmediate(resolve))
|
||
await f.runtime.pollOnce()
|
||
assert.equal(f.prints(), 1)
|
||
assert.equal(f.claims(), 1)
|
||
release()
|
||
await first
|
||
})
|
||
|
||
test('409结果冲突必须先读服务器;同token未知结果保留恢复记录', async () => {
|
||
let reads = 0
|
||
const f = await fixture({
|
||
journal: memoryJournal(pendingResult('completed_pending_ack')),
|
||
report: async () => { throw Object.assign(new Error('conflict'), { status: 409 }) },
|
||
readState: async () => { reads += 1; return { id: 7, claim_token: 'attempt-one', status: 'result_unknown' } }
|
||
})
|
||
await assert.rejects(f.runtime.pollOnce(), /conflict/)
|
||
assert.equal(reads, 1)
|
||
assert.equal(f.journal.entry.phase, 'completed_pending_ack')
|
||
assert.equal(f.prints(), 0)
|
||
})
|
||
|
||
test('管理员已处理旧token时核实服务器后释放恢复记录', async () => {
|
||
const f = await fixture({
|
||
journal: memoryJournal(pendingResult('completed_pending_ack')),
|
||
report: async () => { throw Object.assign(new Error('conflict'), { status: 409 }) },
|
||
readState: async () => ({ id: 7, claim_token: null, status: 'queued' })
|
||
})
|
||
await f.runtime.pollOnce()
|
||
assert.equal(f.journal.entry, null)
|
||
assert.equal(f.claims(), 0)
|
||
assert.match(f.runtime.getState().lastError, /核对/)
|
||
})
|
||
|
||
test('409读回空对象、未知状态或缺token都不能清恢复记录', async () => {
|
||
for (const invalidState of [{}, { status: 'completed' }, { status: 'bogus', claim_token: null }]) {
|
||
const f = await fixture({
|
||
journal: memoryJournal(pendingResult('completed_pending_ack')),
|
||
report: async () => { throw Object.assign(new Error('conflict'), { status: 409 }) },
|
||
readState: async () => invalidState
|
||
})
|
||
await assert.rejects(f.runtime.pollOnce(), /状态响应不完整/)
|
||
assert.equal(f.journal.entry.phase, 'completed_pending_ack')
|
||
assert.equal(f.prints(), 0)
|
||
}
|
||
})
|
||
|
||
test('损坏或属于其他服务器的恢复记录阻止领取', async () => {
|
||
const journal = memoryJournal({ ...pendingResult('dispatching'), serverUrl: 'https://other.example.com' })
|
||
const f = await fixture({ journal })
|
||
await assert.rejects(f.runtime.pollOnce(), /其他服务器/)
|
||
assert.equal(f.claims(), 0)
|
||
})
|
||
|
||
test('真实journal跨实例重启保持阶段,损坏内容不当成无记录', async () => {
|
||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'asset-print-journal-'))
|
||
try {
|
||
const journal = createPrintJournal(root)
|
||
await journal.save(pendingResult('dispatching'))
|
||
assert.deepEqual(await createPrintJournal(root).load(), pendingResult('dispatching'))
|
||
await journal.save(pendingResult('completed_pending_ack'))
|
||
assert.equal((await createPrintJournal(root).load()).phase, 'completed_pending_ack')
|
||
await fs.writeFile(path.join(root, 'pending-print-result.json'), '{bad')
|
||
await assert.rejects(journal.load())
|
||
await journal.clear()
|
||
assert.equal(await journal.load(), null)
|
||
} finally {
|
||
await fs.rm(root, { recursive: true, force: true })
|
||
}
|
||
})
|