@things-factory/ai-assistant 10.1.114 → 10.1.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/client/components/assistant-dock.ts +26 -3
- package/client/setup-assistant-dock.ts +3 -2
- package/client/utils/entry-proposal.ts +14 -1
- package/dist-client/components/assistant-dock.d.ts +5 -0
- package/dist-client/components/assistant-dock.js +25 -3
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/setup-assistant-dock.d.ts +1 -0
- package/dist-client/setup-assistant-dock.js +1 -0
- package/dist-client/setup-assistant-dock.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/entry-proposal.d.ts +10 -1
- package/dist-client/utils/entry-proposal.js +13 -1
- package/dist-client/utils/entry-proposal.js.map +1 -1
- package/dist-server/service/data-entry/entry-apply.d.ts +8 -0
- package/dist-server/service/data-entry/entry-apply.js +19 -2
- package/dist-server/service/data-entry/entry-apply.js.map +1 -1
- package/dist-server/service/data-entry/entry-refusal.d.ts +6 -0
- package/dist-server/service/data-entry/entry-refusal.js +25 -0
- package/dist-server/service/data-entry/entry-refusal.js.map +1 -0
- package/dist-server/service/data-entry/entry-resolver.js +2 -0
- package/dist-server/service/data-entry/entry-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/data-entry/data-entry-update.test.ts +10 -1
- package/server/service/data-entry/data-entry.test.ts +29 -1
- package/server/service/data-entry/entry-apply.ts +27 -2
- package/server/service/data-entry/entry-refusal.test.ts +73 -0
- package/server/service/data-entry/entry-refusal.ts +20 -0
- package/server/service/data-entry/entry-resolver.ts +2 -0
- package/test/entry-proposal.test.ts +31 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.121",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -34,11 +34,11 @@
|
|
|
34
34
|
"@operato/shell": "^10.0.0",
|
|
35
35
|
"@operato/styles": "^10.0.0",
|
|
36
36
|
"@things-factory/ai-client-base": "^10.1.114",
|
|
37
|
-
"@things-factory/apptool-base": "^10.1.
|
|
38
|
-
"@things-factory/attachment-base": "^10.1.
|
|
39
|
-
"@things-factory/auth-base": "^10.1.
|
|
37
|
+
"@things-factory/apptool-base": "^10.1.121",
|
|
38
|
+
"@things-factory/attachment-base": "^10.1.121",
|
|
39
|
+
"@things-factory/auth-base": "^10.1.121",
|
|
40
40
|
"@things-factory/env": "^10.1.20",
|
|
41
|
-
"@things-factory/shell": "^10.1.
|
|
41
|
+
"@things-factory/shell": "^10.1.121",
|
|
42
42
|
"dompurify": "^3.0.0",
|
|
43
43
|
"exceljs": "4.2.0",
|
|
44
44
|
"graphql-scalars": "^1.22.0",
|
|
@@ -52,5 +52,5 @@
|
|
|
52
52
|
"copyfiles": "^2.4.1",
|
|
53
53
|
"rimraf": "^5.0.0"
|
|
54
54
|
},
|
|
55
|
-
"gitHead": "
|
|
55
|
+
"gitHead": "23536a73b10f103048a2c893d3960b2a4e0f03dd"
|
|
56
56
|
}
|
|
@@ -167,6 +167,16 @@ describe('changing what the person pressed', () => {
|
|
|
167
167
|
expect(records.l1.amount).toBe('12400.00')
|
|
168
168
|
})
|
|
169
169
|
|
|
170
|
+
it.each(['Mutation.changeExpenseLine', 'Query.myExpenseLines'])('enriches only the failed update door: %s', async door => {
|
|
171
|
+
const proposal = await card([{ ref: 'l1', values: { amount: 13000 } }])
|
|
172
|
+
const refusal = { message: 'required', extensions: { operation: door.startsWith('Query.') ? 'query' : 'mutation' }, path: [door.split('.')[1]] }
|
|
173
|
+
const refusalFor = jest.fn().mockResolvedValue(refusal)
|
|
174
|
+
const outcome = await applyEntryProposal('u1', deps(proposal, { passesDoor: async asked => asked !== door, refusalFor }))
|
|
175
|
+
expect(outcome).toMatchObject({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, refusal })
|
|
176
|
+
expect(refusalFor.mock.calls).toEqual([[door]])
|
|
177
|
+
expect(records.l1.amount).toBe('12400.00')
|
|
178
|
+
})
|
|
179
|
+
|
|
170
180
|
it('★ a newer 수정안 replaces an older one - an 입력안 of the same record does not', async () => {
|
|
171
181
|
const older = await card([{ ref: 'l1', values: { amount: 13000 } }])
|
|
172
182
|
const entry = { key: 'e1', kind: 'entry', target: 'office.expense-line', rows: [] }
|
|
@@ -347,4 +357,3 @@ describe('a check that cannot run', () => {
|
|
|
347
357
|
expect(proposal.rows[0].issues).toEqual([{ code: 'check-failed', blocking: true }])
|
|
348
358
|
})
|
|
349
359
|
})
|
|
350
|
-
|
|
@@ -157,6 +157,35 @@ describe('entering what the person pressed', () => {
|
|
|
157
157
|
expect(asked[0].variables).toEqual({ line: { spentOn: '2026-09-12', amount: 1 } })
|
|
158
158
|
})
|
|
159
159
|
|
|
160
|
+
it('a closed mutation carries its authoritative refusal without entering any rows', async () => {
|
|
161
|
+
const refusal = { message: 'requires warehouse:mutation', extensions: { code: 'FORBIDDEN', because: 'role', operation: 'mutation', requires: { category: 'warehouse', privilege: 'mutation' } }, path: ['addExpenseLine'] }
|
|
162
|
+
const refusalFor = jest.fn().mockResolvedValue(refusal)
|
|
163
|
+
const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ amount: 1 })])] }]
|
|
164
|
+
expect(await applyEntryProposal('k1', deps(messages, { passesDoor: async () => false, refusalFor }))).toEqual({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, rows: [], refusal })
|
|
165
|
+
expect(refusalFor).toHaveBeenCalledWith('Mutation.addExpenseLine')
|
|
166
|
+
expect(asked).toEqual([])
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('failed refusal enrichment keeps the original refusal and no writes', async () => {
|
|
170
|
+
const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ amount: 1 })])] }]
|
|
171
|
+
expect(await applyEntryProposal('k1', deps(messages, { passesDoor: async () => false, refusalFor: async () => { throw new Error('lookup unavailable') } }))).toEqual({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, rows: [] })
|
|
172
|
+
expect(asked).toEqual([])
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('audits the closed door before best-effort enrichment and tolerates a broken diagnostic sink', async () => {
|
|
176
|
+
const order: string[] = []
|
|
177
|
+
const target = { ...expense, record: async () => { order.push('audit') } }
|
|
178
|
+
const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ amount: 1 })])] }]
|
|
179
|
+
const outcome = await applyEntryProposal('k1', deps(messages, {
|
|
180
|
+
passesDoor: async () => false,
|
|
181
|
+
refusalFor: async () => { order.push('explain'); throw new Error('unavailable') },
|
|
182
|
+
report: () => { throw new Error('diagnostic unavailable') }
|
|
183
|
+
}), () => target)
|
|
184
|
+
expect(order).toEqual(['audit', 'explain'])
|
|
185
|
+
expect(outcome).toMatchObject({ verdict: 'refused', errorCode: 'door-closed', applied: 0 })
|
|
186
|
+
expect(asked).toEqual([])
|
|
187
|
+
})
|
|
188
|
+
|
|
160
189
|
it('★ asks the checks again - a record entered since the card was made keeps its row out', async () => {
|
|
161
190
|
const messages = [{ id: 'm1', proposals: [proposal('k1', [row({ spentOn: '2026-09-12', amount: 1, memo: 'dup' })])] }]
|
|
162
191
|
const outcome = await applyEntryProposal('k1', deps(messages))
|
|
@@ -381,4 +410,3 @@ describe("a moment is the domain's wall time", () => {
|
|
|
381
410
|
expect(result.rows[0].values.startTime).toBe('2026-10-01T08:30:00+09:00')
|
|
382
411
|
})
|
|
383
412
|
})
|
|
384
|
-
|
|
@@ -29,12 +29,20 @@ export interface EntryRowResult {
|
|
|
29
29
|
error?: string
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export interface EntryRefusal {
|
|
33
|
+
message: string
|
|
34
|
+
extensions: Record<string, unknown>
|
|
35
|
+
path: string[]
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
export interface EntryApplyOutcome {
|
|
33
39
|
verdict: 'accepted' | 'refused' | 'unavailable'
|
|
34
40
|
errorCode?: string
|
|
35
41
|
applied: number
|
|
36
42
|
missed: number
|
|
37
43
|
rows: EntryRowResult[]
|
|
44
|
+
/** AUTH's serialized refusal, only when the closed door could be identified. */
|
|
45
|
+
refusal?: EntryRefusal
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
export interface EntryApplyDeps {
|
|
@@ -43,6 +51,7 @@ export interface EntryApplyDeps {
|
|
|
43
51
|
/** Whether an outcome line already answers this key. */
|
|
44
52
|
answered: boolean
|
|
45
53
|
passesDoor(door: string): Promise<boolean>
|
|
54
|
+
refusalFor?(door: string): Promise<EntryRefusal | undefined>
|
|
46
55
|
/**
|
|
47
56
|
* Runs one of the application's queries or mutations with the person's request state - its API, its directives
|
|
48
57
|
* and its own checks. Answers what GraphQL answers.
|
|
@@ -56,6 +65,22 @@ export interface EntryApplyDeps {
|
|
|
56
65
|
|
|
57
66
|
const nothing = (verdict: EntryApplyOutcome['verdict'], errorCode: string): EntryApplyOutcome => ({ verdict, errorCode, applied: 0, missed: 0, rows: [] })
|
|
58
67
|
|
|
68
|
+
/** Called after the existing audit. An unavailable explanation must not change the refusal. */
|
|
69
|
+
async function closedDoor(door: string, deps: EntryApplyDeps): Promise<EntryApplyOutcome> {
|
|
70
|
+
const outcome = nothing('refused', 'door-closed')
|
|
71
|
+
try {
|
|
72
|
+
const refusal = await deps.refusalFor?.(door)
|
|
73
|
+
return refusal ? { ...outcome, refusal } : outcome
|
|
74
|
+
} catch (error) {
|
|
75
|
+
try {
|
|
76
|
+
deps.report?.('[ai-assistant] entry refusal explanation unavailable', error)
|
|
77
|
+
} catch {
|
|
78
|
+
/* A diagnostic sink cannot turn a closed door into a failed proposal request. */
|
|
79
|
+
}
|
|
80
|
+
return outcome
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
59
84
|
export function findEntryProposal(messages: StoredProposalMessage[], key: string): { proposal?: any; superseded: boolean } {
|
|
60
85
|
let at = -1
|
|
61
86
|
let proposal: any
|
|
@@ -85,7 +110,7 @@ export async function applyEntryProposal(key: string, deps: EntryApplyDeps, targ
|
|
|
85
110
|
const door = entryDoorOf(target.mutation)
|
|
86
111
|
if (!(await deps.passesDoor(door))) {
|
|
87
112
|
await recordEntryEvent(target, { phase: 'apply', tool: entryToolName(target.key), rows: 0, ok: false, error: 'door-closed', detail: { proposalKey: key } }, { state: deps.state })
|
|
88
|
-
return
|
|
113
|
+
return closedDoor(door, deps)
|
|
89
114
|
}
|
|
90
115
|
|
|
91
116
|
/* Read again from the stored values - the same rules, and today's records for the target's checks. */
|
|
@@ -176,7 +201,7 @@ async function applyChanges(key: string, proposal: any, target: EntryTarget, dep
|
|
|
176
201
|
for (const door of [rootFieldOf(update.mutation, 'mutation'), rootFieldOf(update.read.query, 'query')]) {
|
|
177
202
|
if (!(await deps.passesDoor(door))) {
|
|
178
203
|
await recordEntryEvent(target, { phase: 'apply', tool, rows: 0, ok: false, error: 'door-closed', detail: { proposalKey: key } }, { state: deps.state })
|
|
179
|
-
return
|
|
204
|
+
return closedDoor(door, deps)
|
|
180
205
|
}
|
|
181
206
|
}
|
|
182
207
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const mockJudge = jest.fn()
|
|
2
|
+
const mockLookup = jest.fn()
|
|
3
|
+
jest.mock('@things-factory/auth-base', () => ({
|
|
4
|
+
judgePermission: (...args: any[]) => mockJudge(...args),
|
|
5
|
+
privilegeOfField: (...args: any[]) => mockLookup(...args),
|
|
6
|
+
privilegeRejection: jest.requireActual('../../../../auth-base/server/service/privilege/privilege-rejection').privilegeRejection
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
import { entryRefusalFor } from './entry-refusal'
|
|
10
|
+
import { requestableFrom } from '../../../../auth-ui/client/utils/access-request'
|
|
11
|
+
|
|
12
|
+
const gate = { category: 'warehouse', privilege: 'mutation', owner: true, super: true }
|
|
13
|
+
const state = { domain: { id: 'd' }, user: { id: 'u' }, unsafeIP: true, prohibitedPrivileges: [{ category: 'warehouse', privilege: 'mutation', by: 'license' }] }
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
mockJudge.mockReset().mockResolvedValue({ permitted: false, because: 'role' })
|
|
17
|
+
mockLookup.mockReset().mockReturnValue({ known: true, gate })
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('uses AUTH\'s full judgement and declaration, not a guess from a closed boolean', async () => {
|
|
21
|
+
const refusal = await entryRefusalFor({ state })('Mutation.updateInboundPlan')
|
|
22
|
+
expect(mockLookup).toHaveBeenCalledWith('Mutation', 'updateInboundPlan')
|
|
23
|
+
expect(mockJudge).toHaveBeenCalledWith(gate, state.user, state.domain, true, state.prohibitedPrivileges)
|
|
24
|
+
expect(refusal).toEqual({
|
|
25
|
+
message: 'unauthorized! warehouse:mutation privilege required',
|
|
26
|
+
extensions: { code: 'FORBIDDEN', requires: { category: 'warehouse', privilege: 'mutation' }, because: 'role', operation: 'mutation' },
|
|
27
|
+
path: ['updateInboundPlan']
|
|
28
|
+
})
|
|
29
|
+
expect(requestableFrom(refusal?.extensions, refusal?.path[0])).toEqual({ category: 'warehouse', privilege: 'mutation', operationName: 'updateInboundPlan' })
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it.each(['address', 'license', 'ownership', 'unknown'])('carries %s without offering a role request', async because => {
|
|
33
|
+
mockJudge.mockResolvedValue({ permitted: false, because })
|
|
34
|
+
const refusal = await entryRefusalFor({ state })('Mutation.updateInboundPlan')
|
|
35
|
+
expect(refusal?.extensions.because).toBe(because)
|
|
36
|
+
expect(requestableFrom(refusal?.extensions)).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('a failed read stays a query even when applyEntryProposal is a mutation', async () => {
|
|
40
|
+
const refusal = await entryRefusalFor({ state })('Query.inboundPlans')
|
|
41
|
+
expect(refusal?.extensions.operation).toBe('query')
|
|
42
|
+
expect(refusal?.path).toEqual(['inboundPlans'])
|
|
43
|
+
expect(requestableFrom(refusal?.extensions)).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('ownership-only carries no named requirement', async () => {
|
|
47
|
+
mockLookup.mockReturnValue({ known: true, gate: { owner: true } })
|
|
48
|
+
mockJudge.mockResolvedValue({ permitted: false, because: 'ownership' })
|
|
49
|
+
expect((await entryRefusalFor({ state })('Mutation.reset'))?.extensions.requires).toBe('ownership')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('unknown, malformed and open doors supply no invented refusal', async () => {
|
|
53
|
+
expect(await entryRefusalFor({ state })('not-a-door')).toBeUndefined()
|
|
54
|
+
expect(mockLookup).not.toHaveBeenCalled()
|
|
55
|
+
for (const lookup of [{ known: false }, { known: true, gate: null }]) {
|
|
56
|
+
mockLookup.mockReturnValue(lookup)
|
|
57
|
+
expect(await entryRefusalFor({ state })('Mutation.updateInboundPlan')).toBeUndefined()
|
|
58
|
+
}
|
|
59
|
+
expect(mockJudge).not.toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('missing caller and a newly permitted judgement supply no refusal', async () => {
|
|
63
|
+
for (const context of [undefined, { state: { user: state.user } }, { state: { domain: state.domain } }])
|
|
64
|
+
expect(await entryRefusalFor(context)('Mutation.updateInboundPlan')).toBeUndefined()
|
|
65
|
+
expect(mockJudge).not.toHaveBeenCalled()
|
|
66
|
+
mockJudge.mockResolvedValue({ permitted: true })
|
|
67
|
+
expect(await entryRefusalFor({ state })('Mutation.updateInboundPlan')).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('uses the request translator for the refusal sentence', async () => {
|
|
71
|
+
const refusal = await entryRefusalFor({ state, t: () => '권한이 필요합니다.' })('Mutation.updateInboundPlan')
|
|
72
|
+
expect(refusal?.message).toBe('권한이 필요합니다.')
|
|
73
|
+
})
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { judgePermission, privilegeOfField, privilegeRejection } from '@things-factory/auth-base'
|
|
2
|
+
import { doorField } from '../chat-message/door-access.js'
|
|
3
|
+
import type { EntryRefusal } from './entry-apply.js'
|
|
4
|
+
|
|
5
|
+
/** Presentation only: AUTH still guards the application's mutation when it is executed. */
|
|
6
|
+
export function entryRefusalFor(context: { state?: any; t?: any } | undefined): (door: string) => Promise<EntryRefusal | undefined> {
|
|
7
|
+
return async door => {
|
|
8
|
+
const { domain, user, unsafeIP, prohibitedPrivileges } = context?.state ?? {}
|
|
9
|
+
const field = doorField(door)
|
|
10
|
+
if (!domain || !user || !field) return undefined
|
|
11
|
+
const asked = privilegeOfField(field.typeName, field.fieldName)
|
|
12
|
+
if (!asked.known || !asked.gate) return undefined
|
|
13
|
+
const judgement = await judgePermission(asked.gate as any, user, domain, unsafeIP, prohibitedPrivileges)
|
|
14
|
+
/* The gate may have opened since the preflight: never invent a refusal to explain that race. */
|
|
15
|
+
if (judgement.permitted !== false) return undefined
|
|
16
|
+
const operation = field.typeName === 'Mutation' ? 'mutation' : field.typeName === 'Query' ? 'query' : 'subscription'
|
|
17
|
+
const error = privilegeRejection(context, asked.gate.category, asked.gate.privilege, judgement.because, operation)
|
|
18
|
+
return { message: error.message, extensions: error.extensions, path: [field.fieldName] }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -13,6 +13,7 @@ import { PROPOSAL_OUTCOME_PREFIX } from '../chat-message/proposal-outcome.js'
|
|
|
13
13
|
import { toolAccessFor } from '../tool-access.js'
|
|
14
14
|
import { applyEntryProposal, entryProposalAnswered, type EntryApplyOutcome } from './entry-apply.js'
|
|
15
15
|
import { runAsCaller } from './entry-tools.js'
|
|
16
|
+
import { entryRefusalFor } from './entry-refusal.js'
|
|
16
17
|
|
|
17
18
|
@Resolver()
|
|
18
19
|
export class EntryProposalResolver {
|
|
@@ -57,6 +58,7 @@ export class EntryProposalResolver {
|
|
|
57
58
|
messages: carrying.map((message: any) => ({ id: message.id, proposals: message.proposalsJson ?? [] })),
|
|
58
59
|
answered: entryProposalAnswered(key, lines.map((line: any) => line.content)),
|
|
59
60
|
passesDoor: door => passes(door),
|
|
61
|
+
refusalFor: entryRefusalFor(context),
|
|
60
62
|
state: context.state,
|
|
61
63
|
/*
|
|
62
64
|
* The application's API, as the person - the same request state the door would have seen had they typed
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
import { readFileSync } from 'fs'
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
|
|
8
|
+
const mockNotify = jest.fn()
|
|
9
|
+
jest.mock('@operato/layout', () => ({ notify: (...args: any[]) => mockNotify(...args) }))
|
|
10
|
+
|
|
8
11
|
import { runProposal, setEntryProposalHandler } from '../client/utils/assistant-proposals'
|
|
9
12
|
import {
|
|
10
13
|
entryGroupOf,
|
|
@@ -78,6 +81,34 @@ describe('the entry card', () => {
|
|
|
78
81
|
expect(outcome).toMatchObject({ verdict: 'accepted', applied: 1, missed: 0 })
|
|
79
82
|
})
|
|
80
83
|
|
|
84
|
+
it('a closed door emits one generic notification and keeps its refused verdict', async () => {
|
|
85
|
+
const refusal = { message: 'required', extensions: { code: 'FORBIDDEN', because: 'role', operation: 'mutation', requires: { category: 'warehouse', privilege: 'mutation' } }, path: ['addExpenseLine'] }
|
|
86
|
+
const report = jest.fn()
|
|
87
|
+
const handler = entryProposalHandler(async () => ({ verdict: 'refused', errorCode: 'door-closed', applied: 0, missed: 0, refusal }), t, () => undefined, report)
|
|
88
|
+
const context = { sessionId: 's', confirm: async () => true, notify: () => {} }
|
|
89
|
+
expect(await handler(card('k'), context)).toMatchObject({ verdict: 'refused', errorCode: 'door-closed', applied: 0 })
|
|
90
|
+
expect(report.mock.calls).toEqual([[{ level: 'error', message: 'required', ex: [refusal] }]])
|
|
91
|
+
const broken = entryProposalHandler(async () => ({ verdict: 'refused', errorCode: 'door-closed', refusal }), t, () => undefined, () => { throw new Error('toast unavailable') })
|
|
92
|
+
expect(await broken(card('k'), context)).toMatchObject({ verdict: 'refused', errorCode: 'door-closed' })
|
|
93
|
+
const rejected = entryProposalHandler(async () => ({ verdict: 'refused', errorCode: 'door-closed', refusal }), t, () => undefined, async () => { throw new Error('async delivery unavailable') })
|
|
94
|
+
expect(await rejected(card('k'), context)).toMatchObject({ verdict: 'refused', errorCode: 'door-closed' })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('successful and stale-review answers emit no refusal notification', async () => {
|
|
98
|
+
const report = jest.fn()
|
|
99
|
+
for (const answer of [{ verdict: 'accepted', applied: 1 }, { verdict: 'refused', errorCode: 'review-stale' }] as EntryApplyAnswer[]) {
|
|
100
|
+
await entryProposalHandler(async () => answer, t, () => undefined, report)(card('k'), { sessionId: 's', confirm: async () => true, notify: () => {} })
|
|
101
|
+
}
|
|
102
|
+
expect(report).not.toHaveBeenCalled()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('the default transport passes the serialized refusal to layout, with no explicit action', async () => {
|
|
106
|
+
mockNotify.mockClear()
|
|
107
|
+
const refusal = { message: 'required', extensions: { code: 'FORBIDDEN' }, path: ['addExpenseLine'] }
|
|
108
|
+
await entryProposalHandler(async () => ({ verdict: 'refused', errorCode: 'door-closed', refusal }), t)(card('k'), { sessionId: 's', confirm: async () => true, notify: () => {} })
|
|
109
|
+
expect(mockNotify.mock.calls).toEqual([[{ level: 'error', message: 'required', ex: [refusal] }]])
|
|
110
|
+
})
|
|
111
|
+
|
|
81
112
|
it('the chat draws the review and names the button by the rows it enters', () => {
|
|
82
113
|
const chat = readFileSync(join(__dirname, '..', 'client', 'components', 'assistant-chat.ts'), 'utf8')
|
|
83
114
|
expect(chat).toContain('<ox-assistant-entry-review .proposal=${p}>')
|
|
@@ -107,4 +138,3 @@ describe('the entry card', () => {
|
|
|
107
138
|
expect(entryReadyCount(p)).toBe(2)
|
|
108
139
|
})
|
|
109
140
|
})
|
|
110
|
-
|