@skopon-cool/form-sdk 0.2.4 → 0.3.0
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/dist/catalog/a2uiCustomCatalog.d.ts.map +1 -1
- package/dist/catalog/skoponCaseSelect.d.ts.map +1 -1
- package/dist/catalog/skoponResumeSelect.d.ts.map +1 -1
- package/dist/catalog/useSkoponBoundField.d.ts +16 -1
- package/dist/catalog/useSkoponBoundField.d.ts.map +1 -1
- package/dist/client/authStorage.d.ts +14 -1
- package/dist/client/authStorage.d.ts.map +1 -1
- package/dist/components/AskUserFormCard.d.ts +8 -1
- package/dist/components/AskUserFormCard.d.ts.map +1 -1
- package/dist/components/CurlSubmitBlock.d.ts +3 -1
- package/dist/components/CurlSubmitBlock.d.ts.map +1 -1
- package/dist/components/SkoponFormRenderer.d.ts +1 -0
- package/dist/components/SkoponFormRenderer.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1579 -1107
- package/dist/runtime/formAutoControl.d.ts +15 -0
- package/dist/runtime/formAutoControl.d.ts.map +1 -0
- package/dist/runtime/formRuntimeContext.d.ts +17 -0
- package/dist/runtime/formRuntimeContext.d.ts.map +1 -0
- package/dist/runtime/formRuntimeWs.d.ts +27 -0
- package/dist/runtime/formRuntimeWs.d.ts.map +1 -0
- package/dist/runtime/types.d.ts +20 -0
- package/dist/runtime/types.d.ts.map +1 -0
- package/dist/submit/buildCurlStatement.d.ts +4 -3
- package/dist/submit/buildCurlStatement.d.ts.map +1 -1
- package/dist/submit/submitAnswerAgent.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/catalog/a2uiCustomCatalog.tsx +41 -16
- package/src/catalog/skoponCaseSelect.tsx +10 -4
- package/src/catalog/skoponResumeSelect.tsx +10 -4
- package/src/catalog/useSkoponBoundField.ts +100 -4
- package/src/client/authStorage.test.ts +74 -1
- package/src/client/authStorage.ts +45 -4
- package/src/components/AskUserFormCard.tsx +178 -31
- package/src/components/CurlSubmitBlock.tsx +14 -3
- package/src/components/SkoponFormRenderer.tsx +12 -0
- package/src/index.ts +3 -0
- package/src/runtime/formAutoControl.test.ts +71 -0
- package/src/runtime/formAutoControl.ts +109 -0
- package/src/runtime/formRuntimeContext.tsx +107 -0
- package/src/runtime/formRuntimeWs.ts +158 -0
- package/src/runtime/types.ts +26 -0
- package/src/submit/buildCurlStatement.ts +20 -3
- package/src/submit/submit.test.ts +81 -0
- package/src/submit/submitAnswerAgent.ts +22 -13
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react'
|
|
9
|
+
import type { FormRuntimeWsClient } from './formRuntimeWs'
|
|
10
|
+
import type { FormRuntimeEventData } from './types'
|
|
11
|
+
|
|
12
|
+
export interface FormRuntimeContextValue {
|
|
13
|
+
reportEvent: (event: FormRuntimeEventData) => void
|
|
14
|
+
reportData: (values: Record<string, unknown>) => void
|
|
15
|
+
runAutoControl: <T>(fn: () => T) => T
|
|
16
|
+
isAutoControl: () => boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const FormRuntimeContext = createContext<FormRuntimeContextValue | null>(null)
|
|
20
|
+
|
|
21
|
+
const noopRuntime: FormRuntimeContextValue = {
|
|
22
|
+
reportEvent: () => {},
|
|
23
|
+
reportData: () => {},
|
|
24
|
+
runAutoControl: (fn) => fn(),
|
|
25
|
+
isAutoControl: () => false,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function omitUndefinedValues(
|
|
29
|
+
values: Record<string, unknown>,
|
|
30
|
+
): Record<string, unknown> {
|
|
31
|
+
const out: Record<string, unknown> = {}
|
|
32
|
+
for (const [key, value] of Object.entries(values)) {
|
|
33
|
+
if (value !== undefined) out[key] = value
|
|
34
|
+
}
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface FormRuntimeProviderProps {
|
|
39
|
+
client: FormRuntimeWsClient | null
|
|
40
|
+
children: ReactNode
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function FormRuntimeProvider({
|
|
44
|
+
client,
|
|
45
|
+
children,
|
|
46
|
+
}: FormRuntimeProviderProps) {
|
|
47
|
+
const autoControlDepthRef = useRef(0)
|
|
48
|
+
const clientRef = useRef(client)
|
|
49
|
+
clientRef.current = client
|
|
50
|
+
|
|
51
|
+
const reportEvent = useCallback((event: FormRuntimeEventData) => {
|
|
52
|
+
const ws = clientRef.current
|
|
53
|
+
if (!ws) return
|
|
54
|
+
const data: Record<string, unknown> = {
|
|
55
|
+
action: event.action,
|
|
56
|
+
component: event.component,
|
|
57
|
+
}
|
|
58
|
+
if (event.field !== undefined) data.field = event.field
|
|
59
|
+
if (event.value !== undefined) data.value = event.value
|
|
60
|
+
const type =
|
|
61
|
+
autoControlDepthRef.current > 0
|
|
62
|
+
? 'form_auto_control_bac_event'
|
|
63
|
+
: 'form_runtime_event'
|
|
64
|
+
ws.send(type, data)
|
|
65
|
+
}, [])
|
|
66
|
+
|
|
67
|
+
const reportData = useCallback((values: Record<string, unknown>) => {
|
|
68
|
+
const ws = clientRef.current
|
|
69
|
+
if (!ws) return
|
|
70
|
+
ws.send('form_runtime_data', omitUndefinedValues(values))
|
|
71
|
+
}, [])
|
|
72
|
+
|
|
73
|
+
const runAutoControl = useCallback(<T,>(fn: () => T): T => {
|
|
74
|
+
autoControlDepthRef.current += 1
|
|
75
|
+
try {
|
|
76
|
+
return fn()
|
|
77
|
+
} finally {
|
|
78
|
+
autoControlDepthRef.current = Math.max(0, autoControlDepthRef.current - 1)
|
|
79
|
+
}
|
|
80
|
+
}, [])
|
|
81
|
+
|
|
82
|
+
const isAutoControl = useCallback(
|
|
83
|
+
() => autoControlDepthRef.current > 0,
|
|
84
|
+
[],
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const value = useMemo(
|
|
88
|
+
(): FormRuntimeContextValue => ({
|
|
89
|
+
reportEvent,
|
|
90
|
+
reportData,
|
|
91
|
+
runAutoControl,
|
|
92
|
+
isAutoControl,
|
|
93
|
+
}),
|
|
94
|
+
[reportEvent, reportData, runAutoControl, isAutoControl],
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<FormRuntimeContext.Provider value={value}>
|
|
99
|
+
{children}
|
|
100
|
+
</FormRuntimeContext.Provider>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 无 Provider 时 no-op(表单管理端预览不受影响)。 */
|
|
105
|
+
export function useFormRuntime(): FormRuntimeContextValue {
|
|
106
|
+
return useContext(FormRuntimeContext) ?? noopRuntime
|
|
107
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FormAutoControlEventData,
|
|
3
|
+
FormRuntimeEnvelope,
|
|
4
|
+
FormRuntimeOutboundType,
|
|
5
|
+
} from './types'
|
|
6
|
+
|
|
7
|
+
const SEND_QUEUE_MAX = 100
|
|
8
|
+
const RECONNECT_MAX_MS = 30_000
|
|
9
|
+
|
|
10
|
+
export type FormAutoControlHandler = (
|
|
11
|
+
data: FormAutoControlEventData,
|
|
12
|
+
envelope: FormRuntimeEnvelope<FormAutoControlEventData>,
|
|
13
|
+
) => void
|
|
14
|
+
|
|
15
|
+
export interface FormRuntimeWsClientOptions {
|
|
16
|
+
url: string
|
|
17
|
+
askUserMessageId: string
|
|
18
|
+
onAutoControl?: FormAutoControlHandler
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class FormRuntimeWsClient {
|
|
22
|
+
private readonly url: string
|
|
23
|
+
private readonly askUserMessageId: string
|
|
24
|
+
private readonly onAutoControl?: FormAutoControlHandler
|
|
25
|
+
private ws: WebSocket | null = null
|
|
26
|
+
private closed = false
|
|
27
|
+
private reconnectAttempt = 0
|
|
28
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
29
|
+
private sendQueue: string[] = []
|
|
30
|
+
|
|
31
|
+
constructor(options: FormRuntimeWsClientOptions) {
|
|
32
|
+
this.url = options.url
|
|
33
|
+
this.askUserMessageId = options.askUserMessageId
|
|
34
|
+
this.onAutoControl = options.onAutoControl
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
connect(): void {
|
|
38
|
+
if (this.closed || typeof WebSocket === 'undefined') return
|
|
39
|
+
this.clearReconnectTimer()
|
|
40
|
+
try {
|
|
41
|
+
const ws = new WebSocket(this.url)
|
|
42
|
+
this.ws = ws
|
|
43
|
+
ws.onopen = () => {
|
|
44
|
+
this.reconnectAttempt = 0
|
|
45
|
+
this.flushQueue()
|
|
46
|
+
}
|
|
47
|
+
ws.onmessage = (event) => {
|
|
48
|
+
this.handleMessage(event.data)
|
|
49
|
+
}
|
|
50
|
+
ws.onclose = () => {
|
|
51
|
+
this.ws = null
|
|
52
|
+
this.scheduleReconnect()
|
|
53
|
+
}
|
|
54
|
+
ws.onerror = () => {
|
|
55
|
+
// onclose will follow; avoid noisy logs
|
|
56
|
+
}
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.warn('[FormRuntimeWs] connect failed', error)
|
|
59
|
+
this.scheduleReconnect()
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
send(
|
|
64
|
+
type: FormRuntimeOutboundType,
|
|
65
|
+
data: Record<string, unknown>,
|
|
66
|
+
): void {
|
|
67
|
+
if (this.closed) return
|
|
68
|
+
const payload: FormRuntimeEnvelope = {
|
|
69
|
+
type,
|
|
70
|
+
ask_user_message_id: this.askUserMessageId,
|
|
71
|
+
data,
|
|
72
|
+
}
|
|
73
|
+
let serialized: string
|
|
74
|
+
try {
|
|
75
|
+
serialized = JSON.stringify(payload)
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.warn('[FormRuntimeWs] serialize failed', error)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
81
|
+
this.ws.send(serialized)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
this.enqueue(serialized)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
close(): void {
|
|
88
|
+
this.closed = true
|
|
89
|
+
this.clearReconnectTimer()
|
|
90
|
+
this.sendQueue = []
|
|
91
|
+
if (this.ws) {
|
|
92
|
+
this.ws.onopen = null
|
|
93
|
+
this.ws.onmessage = null
|
|
94
|
+
this.ws.onclose = null
|
|
95
|
+
this.ws.onerror = null
|
|
96
|
+
try {
|
|
97
|
+
this.ws.close()
|
|
98
|
+
} catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
this.ws = null
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private enqueue(serialized: string): void {
|
|
106
|
+
this.sendQueue.push(serialized)
|
|
107
|
+
while (this.sendQueue.length > SEND_QUEUE_MAX) {
|
|
108
|
+
this.sendQueue.shift()
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private flushQueue(): void {
|
|
113
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
|
|
114
|
+
const pending = this.sendQueue
|
|
115
|
+
this.sendQueue = []
|
|
116
|
+
for (const item of pending) {
|
|
117
|
+
this.ws.send(item)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private scheduleReconnect(): void {
|
|
122
|
+
if (this.closed) return
|
|
123
|
+
this.clearReconnectTimer()
|
|
124
|
+
const delay = Math.min(
|
|
125
|
+
RECONNECT_MAX_MS,
|
|
126
|
+
1000 * 2 ** Math.min(this.reconnectAttempt, 5),
|
|
127
|
+
)
|
|
128
|
+
this.reconnectAttempt += 1
|
|
129
|
+
this.reconnectTimer = setTimeout(() => {
|
|
130
|
+
this.reconnectTimer = null
|
|
131
|
+
this.connect()
|
|
132
|
+
}, delay)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private clearReconnectTimer(): void {
|
|
136
|
+
if (this.reconnectTimer != null) {
|
|
137
|
+
clearTimeout(this.reconnectTimer)
|
|
138
|
+
this.reconnectTimer = null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private handleMessage(raw: unknown): void {
|
|
143
|
+
if (typeof raw !== 'string') return
|
|
144
|
+
let parsed: unknown
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(raw)
|
|
147
|
+
} catch {
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return
|
|
151
|
+
const envelope = parsed as FormRuntimeEnvelope<FormAutoControlEventData>
|
|
152
|
+
if (envelope.type !== 'form_auto_control_event') return
|
|
153
|
+
if (String(envelope.ask_user_message_id ?? '') !== this.askUserMessageId) return
|
|
154
|
+
const data = envelope.data
|
|
155
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return
|
|
156
|
+
this.onAutoControl?.(data, envelope)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type FormRuntimeAction = 'focus' | 'change' | 'select' | 'click' | 'submit'
|
|
2
|
+
|
|
3
|
+
export interface FormRuntimeEventData {
|
|
4
|
+
action: FormRuntimeAction
|
|
5
|
+
component: string
|
|
6
|
+
field?: string
|
|
7
|
+
value?: unknown
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FormAutoControlEventData {
|
|
11
|
+
action: FormRuntimeAction
|
|
12
|
+
field?: string
|
|
13
|
+
value?: unknown
|
|
14
|
+
component?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type FormRuntimeOutboundType =
|
|
18
|
+
| 'form_runtime_data'
|
|
19
|
+
| 'form_runtime_event'
|
|
20
|
+
| 'form_auto_control_bac_event'
|
|
21
|
+
|
|
22
|
+
export interface FormRuntimeEnvelope<T = Record<string, unknown>> {
|
|
23
|
+
type: string
|
|
24
|
+
ask_user_message_id: string
|
|
25
|
+
data: T
|
|
26
|
+
}
|
|
@@ -7,12 +7,25 @@ function escapeSingleQuotes(text: string): string {
|
|
|
7
7
|
return text.replace(/'/g, `'\\''`)
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
function resolveAuthorizationCurlHeader(headers?: Record<string, string>): string {
|
|
11
|
+
const authorization = headers?.Authorization?.trim()
|
|
12
|
+
if (authorization) {
|
|
13
|
+
return ` -H 'Authorization: ${escapeSingleQuotes(authorization)}' \\`
|
|
14
|
+
}
|
|
15
|
+
return ` -H 'Authorization: Bearer <token>' \\`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function buildCurlStatement(
|
|
19
|
+
payload: unknown,
|
|
20
|
+
callbackUrl?: string | null,
|
|
21
|
+
headers?: Record<string, string>,
|
|
22
|
+
): string {
|
|
11
23
|
const url = (callbackUrl ?? '').trim() || '<callback_url>'
|
|
12
24
|
const body = JSON.stringify(payload ?? {}, null, 2)
|
|
13
25
|
return [
|
|
14
26
|
`curl -X POST '${url}' \\`,
|
|
15
27
|
` -H 'Content-Type: application/json' \\`,
|
|
28
|
+
resolveAuthorizationCurlHeader(headers),
|
|
16
29
|
` -d '${escapeSingleQuotes(body)}'`,
|
|
17
30
|
].join('\n')
|
|
18
31
|
}
|
|
@@ -29,18 +42,21 @@ export interface BuildAskUserCurlStatementOptions {
|
|
|
29
42
|
cardValues: Record<string, unknown>
|
|
30
43
|
extraValues?: Record<string, unknown>
|
|
31
44
|
callbackUrl?: string | null
|
|
45
|
+
headers?: Record<string, string>
|
|
32
46
|
}
|
|
33
47
|
|
|
34
48
|
export function buildAskUserCurlStatement({
|
|
35
49
|
cardValues,
|
|
36
50
|
extraValues = {},
|
|
37
51
|
callbackUrl,
|
|
52
|
+
headers,
|
|
38
53
|
}: BuildAskUserCurlStatementOptions): string {
|
|
39
54
|
const url = (callbackUrl ?? '').trim() || '<callback_url>'
|
|
40
55
|
const body = buildAskUserCurlBodyJson(cardValues, extraValues)
|
|
41
56
|
return [
|
|
42
57
|
`curl -X POST '${url}' \\`,
|
|
43
58
|
` -H 'Content-Type: application/json' \\`,
|
|
59
|
+
resolveAuthorizationCurlHeader(headers),
|
|
44
60
|
` -d '${escapeSingleQuotes(body)}'`,
|
|
45
61
|
].join('\n')
|
|
46
62
|
}
|
|
@@ -49,9 +65,10 @@ export function buildAskUserCurlStatement({
|
|
|
49
65
|
export function buildAskUserSubmitCurlFromPayload(
|
|
50
66
|
payload: unknown,
|
|
51
67
|
callbackUrl?: string | null,
|
|
68
|
+
headers?: Record<string, string>,
|
|
52
69
|
): string {
|
|
53
70
|
const def = parsePayloadBlocksJson(payload)
|
|
54
|
-
if (!def) return buildCurlStatement(payload, callbackUrl)
|
|
71
|
+
if (!def) return buildCurlStatement(payload, callbackUrl, headers)
|
|
55
72
|
const cardValues = extractExtraBlockValues(def.blocks)
|
|
56
|
-
return buildAskUserCurlStatement({ cardValues, callbackUrl })
|
|
73
|
+
return buildAskUserCurlStatement({ cardValues, callbackUrl, headers })
|
|
57
74
|
}
|
|
@@ -30,6 +30,16 @@ describe('buildCurlStatement', () => {
|
|
|
30
30
|
const curl = buildCurlStatement({ name: "O'Brien" }, 'https://example.com/hook')
|
|
31
31
|
expect(curl).toContain("curl -X POST 'https://example.com/hook'")
|
|
32
32
|
expect(curl).toContain("O'\\''Brien")
|
|
33
|
+
expect(curl).toContain("Authorization: Bearer <token>")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('includes Authorization header when provided', () => {
|
|
37
|
+
const curl = buildCurlStatement(
|
|
38
|
+
{ a: 1 },
|
|
39
|
+
'https://example.com/hook',
|
|
40
|
+
{ Authorization: 'Bearer tok-1' },
|
|
41
|
+
)
|
|
42
|
+
expect(curl).toContain("Authorization: Bearer tok-1")
|
|
33
43
|
})
|
|
34
44
|
|
|
35
45
|
it('uses placeholder when callback url missing', () => {
|
|
@@ -90,6 +100,21 @@ describe('buildAskUserSubmitCurlFromPayload', () => {
|
|
|
90
100
|
buildCurlStatement(payload, callbackUrl),
|
|
91
101
|
)
|
|
92
102
|
})
|
|
103
|
+
|
|
104
|
+
it('includes Authorization header in submit-fields curl', () => {
|
|
105
|
+
const payload = {
|
|
106
|
+
title: 'Ask',
|
|
107
|
+
blocks: [
|
|
108
|
+
{ id: 'p1', type: 'text', name: 'video_title', label: '标题', defaultValue: 'hello' },
|
|
109
|
+
],
|
|
110
|
+
}
|
|
111
|
+
const curl = buildAskUserSubmitCurlFromPayload(
|
|
112
|
+
payload,
|
|
113
|
+
'https://example.com/hook',
|
|
114
|
+
{ Authorization: 'Bearer tok-1' },
|
|
115
|
+
)
|
|
116
|
+
expect(curl).toContain("Authorization: Bearer tok-1")
|
|
117
|
+
})
|
|
93
118
|
})
|
|
94
119
|
|
|
95
120
|
describe('parsePayloadBlocksJson', () => {
|
|
@@ -409,6 +434,35 @@ describe('submitAnswerAgent', () => {
|
|
|
409
434
|
'callback_url 为空,无法提交',
|
|
410
435
|
)
|
|
411
436
|
})
|
|
437
|
+
|
|
438
|
+
it('throws when explicit answer agent callback url is empty', async () => {
|
|
439
|
+
await expect(
|
|
440
|
+
submitAnswerAgent(
|
|
441
|
+
{
|
|
442
|
+
mission_id: 1,
|
|
443
|
+
tenant_id: 1001,
|
|
444
|
+
tenant_user_id: 'u1',
|
|
445
|
+
request_id: 'req-1',
|
|
446
|
+
callback_url: 'https://example.com/cb',
|
|
447
|
+
answer: {},
|
|
448
|
+
},
|
|
449
|
+
{ answerAgentCallbackUrl: ' ' },
|
|
450
|
+
),
|
|
451
|
+
).rejects.toThrow('answer_agent_callback_url 未配置,无法提交')
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
it('throws when answer agent callback url cannot be resolved', async () => {
|
|
455
|
+
await expect(
|
|
456
|
+
submitAnswerAgent({
|
|
457
|
+
mission_id: 1,
|
|
458
|
+
tenant_id: 1001,
|
|
459
|
+
tenant_user_id: 'u1',
|
|
460
|
+
request_id: 'req-1',
|
|
461
|
+
callback_url: 'https://example.com/cb',
|
|
462
|
+
answer: {},
|
|
463
|
+
}),
|
|
464
|
+
).rejects.toThrow('answer_agent_callback_url 未配置,无法提交')
|
|
465
|
+
})
|
|
412
466
|
})
|
|
413
467
|
|
|
414
468
|
describe('submitFormJson', () => {
|
|
@@ -434,6 +488,33 @@ describe('submitFormJson', () => {
|
|
|
434
488
|
}),
|
|
435
489
|
)
|
|
436
490
|
})
|
|
491
|
+
|
|
492
|
+
it('POSTs JSON payload with Authorization header', async () => {
|
|
493
|
+
const fetchMock = vi.fn(async () => ({
|
|
494
|
+
ok: true,
|
|
495
|
+
status: 200,
|
|
496
|
+
headers: { get: () => 'application/json' },
|
|
497
|
+
json: async () => ({ ok: true }),
|
|
498
|
+
})) as unknown as typeof fetch
|
|
499
|
+
|
|
500
|
+
await submitFormJson(
|
|
501
|
+
'https://example.com/hook',
|
|
502
|
+
{ a: 1 },
|
|
503
|
+
{
|
|
504
|
+
fetch: fetchMock,
|
|
505
|
+
headers: { Authorization: 'Bearer tok-1' },
|
|
506
|
+
},
|
|
507
|
+
)
|
|
508
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
509
|
+
'https://example.com/hook',
|
|
510
|
+
expect.objectContaining({
|
|
511
|
+
headers: expect.objectContaining({
|
|
512
|
+
Authorization: 'Bearer tok-1',
|
|
513
|
+
'Content-Type': 'application/json',
|
|
514
|
+
}),
|
|
515
|
+
}),
|
|
516
|
+
)
|
|
517
|
+
})
|
|
437
518
|
})
|
|
438
519
|
|
|
439
520
|
describe('validateFormSubmitValues', () => {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
-
DEFAULT_ANSWER_AGENT_CALLBACK_URL,
|
|
3
2
|
resolveAnswerAgentCallbackUrl,
|
|
4
|
-
|
|
3
|
+
resolveSubmitAuthHeaders,
|
|
5
4
|
type SkoponAuthStorageOptions,
|
|
6
5
|
} from '../client/authStorage'
|
|
7
6
|
import { validateAnswerAgentBody } from './buildAnswerAgentBody'
|
|
@@ -17,6 +16,25 @@ export interface SubmitAnswerAgentOptions extends SubmitFormJsonOptions {
|
|
|
17
16
|
answerAgentCallbackUrl?: string | null
|
|
18
17
|
}
|
|
19
18
|
|
|
19
|
+
const ANSWER_AGENT_CALLBACK_URL_MISSING_ERROR =
|
|
20
|
+
'answer_agent_callback_url 未配置,无法提交'
|
|
21
|
+
|
|
22
|
+
function resolveSubmitAnswerAgentUrl(options?: SubmitAnswerAgentOptions): string {
|
|
23
|
+
if (options?.answerAgentCallbackUrl != null) {
|
|
24
|
+
const explicit = options.answerAgentCallbackUrl.trim()
|
|
25
|
+
if (!explicit) {
|
|
26
|
+
throw new Error(ANSWER_AGENT_CALLBACK_URL_MISSING_ERROR)
|
|
27
|
+
}
|
|
28
|
+
return explicit
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const resolved = resolveAnswerAgentCallbackUrl(options?.authStorage).trim()
|
|
32
|
+
if (!resolved) {
|
|
33
|
+
throw new Error(ANSWER_AGENT_CALLBACK_URL_MISSING_ERROR)
|
|
34
|
+
}
|
|
35
|
+
return resolved
|
|
36
|
+
}
|
|
37
|
+
|
|
20
38
|
interface AnswerAgentResponseData {
|
|
21
39
|
callback_ok?: boolean
|
|
22
40
|
callback_status?: number
|
|
@@ -79,20 +97,11 @@ export async function submitAnswerAgent(
|
|
|
79
97
|
throw new Error(validationError)
|
|
80
98
|
}
|
|
81
99
|
|
|
82
|
-
const url = (
|
|
83
|
-
options?.answerAgentCallbackUrl?.trim() ||
|
|
84
|
-
resolveAnswerAgentCallbackUrl(options?.authStorage) ||
|
|
85
|
-
DEFAULT_ANSWER_AGENT_CALLBACK_URL
|
|
86
|
-
).trim()
|
|
87
|
-
|
|
88
|
-
const storageHeaders = resolveSkoponAuthHeaders(options?.authStorage)
|
|
100
|
+
const url = resolveSubmitAnswerAgentUrl(options)
|
|
89
101
|
|
|
90
102
|
const result = await submitFormJson(url, body, {
|
|
91
103
|
...options,
|
|
92
|
-
headers:
|
|
93
|
-
...storageHeaders,
|
|
94
|
-
...(options?.headers ?? {}),
|
|
95
|
-
},
|
|
104
|
+
headers: resolveSubmitAuthHeaders(options?.headers, options?.authStorage),
|
|
96
105
|
})
|
|
97
106
|
|
|
98
107
|
const data = extractAnswerAgentData(result.body)
|