feat(ai): add tenant-safe hierarchical expense learning
This commit is contained in:
190
web/tests/settings-expense-memory-section.test.mjs
Normal file
190
web/tests/settings-expense-memory-section.test.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
buildOrganizationExpenseMemoryCreatePayload,
|
||||
buildOrganizationExpenseMemoryRevokePayload,
|
||||
buildOrganizationExpenseMemoryUpdatePayload,
|
||||
createOrganizationExpenseMemory,
|
||||
fetchOrganizationExpenseMemories,
|
||||
normalizeOrganizationExpenseMemory,
|
||||
revokeOrganizationExpenseMemory,
|
||||
updateOrganizationExpenseMemory
|
||||
} from '../src/services/expenseApplicationMemories.js'
|
||||
|
||||
const settingsModel = readFileSync(new URL('../src/utils/settingsModelHelper.js', import.meta.url), 'utf8')
|
||||
const settingsView = readFileSync(new URL('../src/views/SettingsView.vue', import.meta.url), 'utf8')
|
||||
const settingsScript = readFileSync(new URL('../src/views/scripts/SettingsView.js', import.meta.url), 'utf8')
|
||||
const memoryPanel = readFileSync(new URL('../src/views/ExpenseMemorySettingsPanel.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('组织记忆 payload 限制低敏枚举、有效期和必填理由', () => {
|
||||
assert.deepEqual(buildOrganizationExpenseMemoryCreatePayload({
|
||||
scopeType: 'enterprise',
|
||||
scopeId: 'must-not-be-sent',
|
||||
value: '火车',
|
||||
expiresInDays: 180,
|
||||
reason: '统一企业基线',
|
||||
requestId: 'create-enterprise-1'
|
||||
}), {
|
||||
scope_type: 'enterprise',
|
||||
value: '火车',
|
||||
expires_in_days: 180,
|
||||
reason: '统一企业基线',
|
||||
request_id: 'create-enterprise-1'
|
||||
})
|
||||
assert.deepEqual(buildOrganizationExpenseMemoryCreatePayload({
|
||||
scopeType: 'department',
|
||||
scopeId: 'organization-unit-stable-id',
|
||||
value: '飞机',
|
||||
expiresInDays: 90,
|
||||
reason: '部门差旅基线',
|
||||
requestId: 'create-department-1'
|
||||
}), {
|
||||
scope_type: 'department',
|
||||
scope_id: 'organization-unit-stable-id',
|
||||
value: '飞机',
|
||||
expires_in_days: 90,
|
||||
reason: '部门差旅基线',
|
||||
request_id: 'create-department-1'
|
||||
})
|
||||
assert.deepEqual(buildOrganizationExpenseMemoryUpdatePayload({
|
||||
value: '轮船',
|
||||
expiresInDays: 120,
|
||||
expectedGeneration: 3,
|
||||
reason: '更新制度',
|
||||
requestId: 'update-memory-3'
|
||||
}), {
|
||||
value: '轮船',
|
||||
expires_in_days: 120,
|
||||
expected_generation: 3,
|
||||
reason: '更新制度',
|
||||
request_id: 'update-memory-3'
|
||||
})
|
||||
assert.deepEqual(buildOrganizationExpenseMemoryRevokePayload({
|
||||
expectedGeneration: 3,
|
||||
reason: '制度已停用',
|
||||
requestId: 'revoke-memory-3'
|
||||
}), {
|
||||
expected_generation: 3,
|
||||
reason: '制度已停用',
|
||||
request_id: 'revoke-memory-3'
|
||||
})
|
||||
assert.throws(
|
||||
() => buildOrganizationExpenseMemoryCreatePayload({
|
||||
scopeType: 'enterprise', value: '网约车', expiresInDays: 180, reason: '不应通过', requestId: 'invalid-value-1'
|
||||
}),
|
||||
/仅支持飞机、火车或轮船/
|
||||
)
|
||||
assert.throws(
|
||||
() => buildOrganizationExpenseMemoryCreatePayload({
|
||||
scopeType: 'enterprise', value: '火车', expiresInDays: 29, reason: '不应通过', requestId: 'invalid-days-1'
|
||||
}),
|
||||
/30 到 365 天/
|
||||
)
|
||||
assert.throws(
|
||||
() => buildOrganizationExpenseMemoryCreatePayload({
|
||||
scopeType: 'enterprise', value: '火车', expiresInDays: 180, reason: '', requestId: 'invalid-reason-1'
|
||||
}),
|
||||
/操作理由/
|
||||
)
|
||||
})
|
||||
|
||||
test('组织记忆 service 使用管理员接口并保留作用域和版本解释', async () => {
|
||||
const requests = []
|
||||
const originalFetch = globalThis.fetch
|
||||
const responseMemory = {
|
||||
id: 'memory/enterprise 1',
|
||||
field_key: 'transport_mode',
|
||||
value: '火车',
|
||||
status: 'active',
|
||||
scope_type: 'enterprise',
|
||||
scope_id: 'tenant-1',
|
||||
scope_label: '企业统一规则',
|
||||
source: 'enterprise_policy_memory',
|
||||
origin_type: 'admin_managed',
|
||||
generation: 2,
|
||||
policy_version: 'expense_application_transport_org_memory.v1',
|
||||
expires_at: '2026-12-31T00:00:00Z',
|
||||
managed_by: 'private-admin-identifier',
|
||||
can_revoke: true
|
||||
}
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
requests.push({ url: String(url), options })
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async json() {
|
||||
return requests.length === 1 ? { items: [responseMemory] } : responseMemory
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const items = await fetchOrganizationExpenseMemories()
|
||||
await createOrganizationExpenseMemory({
|
||||
scopeType: 'enterprise', value: '火车', expiresInDays: 180, reason: '创建企业记忆', requestId: 'create-memory-1'
|
||||
})
|
||||
await updateOrganizationExpenseMemory('memory/enterprise 1', {
|
||||
value: '轮船', expiresInDays: 120, expectedGeneration: 2, reason: '更新记忆', requestId: 'update-memory-1'
|
||||
})
|
||||
await revokeOrganizationExpenseMemory('memory/enterprise 1', {
|
||||
expectedGeneration: 2, reason: '撤销记忆', requestId: 'revoke-memory-1'
|
||||
})
|
||||
|
||||
assert.equal(items[0].scopeType, 'enterprise')
|
||||
assert.equal(items[0].generation, 2)
|
||||
assert.equal(items[0].sourceLabel, '管理员显式维护')
|
||||
assert.equal('managedBy' in items[0], false)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
assert.equal(requests[0].url, '/api/v1/expense-application-memories/organization')
|
||||
assert.equal(requests[1].options.method, 'POST')
|
||||
assert.equal(
|
||||
requests[2].url,
|
||||
'/api/v1/expense-application-memories/organization/memory%2Fenterprise%201'
|
||||
)
|
||||
assert.equal(requests[2].options.method, 'PUT')
|
||||
assert.equal(
|
||||
requests[3].url,
|
||||
'/api/v1/expense-application-memories/organization/memory%2Fenterprise%201/revoke'
|
||||
)
|
||||
assert.equal(requests[3].options.method, 'POST')
|
||||
})
|
||||
|
||||
test('组织记忆规范化不将管理员标识带入展示模型', () => {
|
||||
const memory = normalizeOrganizationExpenseMemory({
|
||||
id: 'memory-department-1',
|
||||
scope_type: 'department',
|
||||
scope_id: 'organization-unit-1',
|
||||
scope_label: '部门规则(财务部)',
|
||||
status: 'active',
|
||||
value: '飞机',
|
||||
origin_type: 'admin_managed',
|
||||
managed_by: 'sensitive-user-id'
|
||||
})
|
||||
assert.equal(memory.scopeId, 'organization-unit-1')
|
||||
assert.equal(memory.scopeLabel, '部门规则(财务部)')
|
||||
assert.equal(memory.sourceLabel, '管理员显式维护')
|
||||
assert.equal('managedBy' in memory, false)
|
||||
})
|
||||
|
||||
test('设置页接入无顶部保存按钮的 AI 记忆独立面板', () => {
|
||||
assert.match(settingsModel, /id:\s*'aiMemory'[\s\S]*label:\s*'AI 记忆'[\s\S]*actionLabel:\s*''/)
|
||||
assert.match(settingsModel, /aiMemory:\s*true/)
|
||||
assert.match(settingsScript, /import ExpenseMemorySettingsPanel/)
|
||||
assert.match(settingsScript, /ExpenseMemorySettingsPanel,/)
|
||||
assert.match(settingsView, /activeSection === 'aiMemory'[\s\S]*<ExpenseMemorySettingsPanel \/>/)
|
||||
assert.match(memoryPanel, /fetchEmployeeMeta\(\)/)
|
||||
assert.match(memoryPanel, /item\?\.id && item\?\.unitType === 'department'/)
|
||||
assert.match(memoryPanel, /value:\s*String\(item\.id\)/)
|
||||
assert.match(memoryPanel, /飞机[、\s\S]*火车[、\s\S]*轮船/)
|
||||
assert.match(memoryPanel, /min="30" max="365"/)
|
||||
assert.match(memoryPanel, /第 \{\{ memory\.generation \}\} 版/)
|
||||
assert.match(memoryPanel, /memory\.sourceLabel/)
|
||||
assert.match(memoryPanel, /organization-memory-revoke:/)
|
||||
assert.match(memoryPanel, /requestId:\s*revokeRequestId\.value/)
|
||||
assert.doesNotMatch(memoryPanel, /managedBy|managed_by|managementReason|management_reason/)
|
||||
})
|
||||
Reference in New Issue
Block a user