bingocode 1.0.26 → 1.0.28

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.
Files changed (65) hide show
  1. package/config/bingo-defaults/settings.json +2 -1
  2. package/package.json +1 -2
  3. package/src/server/services/providerService.ts +104 -0
  4. package/src/utils/managedEnv.ts +1 -17
  5. package/.github/FUNDING.yml +0 -1
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -44
  7. package/.github/ISSUE_TEMPLATE/config.yml +0 -1
  8. package/.github/ISSUE_TEMPLATE/question.md +0 -40
  9. package/.github/workflows/build-desktop-dev.yml +0 -210
  10. package/.github/workflows/deploy-docs.yml +0 -59
  11. package/.github/workflows/release-desktop.yml +0 -162
  12. package/.spine/user.yaml +0 -5
  13. package/.spine/workspace.yaml +0 -1
  14. package/adapters/common/__tests__/chat-queue.test.ts +0 -61
  15. package/adapters/common/__tests__/format.test.ts +0 -148
  16. package/adapters/common/__tests__/http-client.test.ts +0 -105
  17. package/adapters/common/__tests__/message-buffer.test.ts +0 -84
  18. package/adapters/common/__tests__/message-dedup.test.ts +0 -57
  19. package/adapters/common/__tests__/session-store.test.ts +0 -62
  20. package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
  21. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
  22. package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
  23. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
  24. package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
  25. package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
  26. package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
  27. package/adapters/feishu/__tests__/feishu.test.ts +0 -907
  28. package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
  29. package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
  30. package/adapters/feishu/__tests__/media.test.ts +0 -120
  31. package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
  32. package/adapters/telegram/__tests__/media.test.ts +0 -86
  33. package/adapters/telegram/__tests__/telegram.test.ts +0 -115
  34. package/adapters/tsconfig.json +0 -18
  35. package/bunfig.toml +0 -1
  36. package/preload.ts +0 -30
  37. package/scripts/count-app-loc.ts +0 -256
  38. package/scripts/release.ts +0 -130
  39. package/src/server/__tests__/conversation-service.test.ts +0 -173
  40. package/src/server/__tests__/conversations.test.ts +0 -458
  41. package/src/server/__tests__/cron-scheduler.test.ts +0 -575
  42. package/src/server/__tests__/e2e/business-flow.test.ts +0 -841
  43. package/src/server/__tests__/e2e/full-flow.test.ts +0 -357
  44. package/src/server/__tests__/fixtures/mock-sdk-cli.ts +0 -123
  45. package/src/server/__tests__/haha-oauth-api.test.ts +0 -146
  46. package/src/server/__tests__/haha-oauth-service.test.ts +0 -185
  47. package/src/server/__tests__/providers-real.test.ts +0 -244
  48. package/src/server/__tests__/providers.test.ts +0 -579
  49. package/src/server/__tests__/proxy-streaming.test.ts +0 -317
  50. package/src/server/__tests__/proxy-transform.test.ts +0 -469
  51. package/src/server/__tests__/real-llm-test.ts +0 -526
  52. package/src/server/__tests__/scheduled-tasks.test.ts +0 -371
  53. package/src/server/__tests__/sessions.test.ts +0 -786
  54. package/src/server/__tests__/settings.test.ts +0 -376
  55. package/src/server/__tests__/skills.test.ts +0 -125
  56. package/src/server/__tests__/tasks.test.ts +0 -171
  57. package/src/server/__tests__/team-watcher.test.ts +0 -400
  58. package/src/server/__tests__/teams.test.ts +0 -627
  59. package/src/server/middleware/cors.test.ts +0 -27
  60. package/src/utils/__tests__/cronFrequency.test.ts +0 -153
  61. package/src/utils/__tests__/cronTasks.test.ts +0 -204
  62. package/src/utils/computerUse/permissions.test.ts +0 -44
  63. package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
  64. package/stubs/color-diff-napi.ts +0 -45
  65. package/tsconfig.json +0 -24
@@ -1,177 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2
- import { WsBridge } from '../ws-bridge.js'
3
- import { WebSocketServer, type WebSocket as WsServerSocket } from 'ws'
4
-
5
- describe('WsBridge', () => {
6
- let bridge: WsBridge
7
-
8
- beforeEach(() => {
9
- bridge = new WsBridge('ws://127.0.0.1:19999', 'test')
10
- })
11
-
12
- afterEach(() => {
13
- bridge.destroy()
14
- })
15
-
16
- it('connectSession connects with provided sessionId', () => {
17
- const result = bridge.connectSession('chat-1', 'my-uuid-session-id')
18
- expect(result).toBe(true)
19
- expect(bridge.hasSession('chat-1')).toBe(true)
20
- })
21
-
22
- it('connectSession for different chatIds creates separate sessions', () => {
23
- bridge.connectSession('chat-1', 'uuid-1')
24
- bridge.connectSession('chat-2', 'uuid-2')
25
- expect(bridge.hasSession('chat-1')).toBe(true)
26
- expect(bridge.hasSession('chat-2')).toBe(true)
27
- })
28
-
29
- it('resetSession removes the session', () => {
30
- bridge.connectSession('chat-reset', 'uuid-reset')
31
- bridge.resetSession('chat-reset')
32
- expect(bridge.hasSession('chat-reset')).toBe(false)
33
- })
34
-
35
- it('sendUserMessage returns false when no open connection', () => {
36
- bridge.connectSession('chat-offline', 'uuid-offline')
37
- expect(bridge.sendUserMessage('chat-offline', 'hello')).toBe(false)
38
- })
39
-
40
- it('sendPermissionResponse returns false when no open connection', () => {
41
- bridge.connectSession('chat-perm', 'uuid-perm')
42
- expect(bridge.sendPermissionResponse('chat-perm', 'req-1', true)).toBe(false)
43
- })
44
-
45
- it('sendStopGeneration returns false when no open connection', () => {
46
- bridge.connectSession('chat-stop', 'uuid-stop')
47
- expect(bridge.sendStopGeneration('chat-stop')).toBe(false)
48
- })
49
-
50
- it('destroy cleans up all sessions', () => {
51
- bridge.connectSession('a', 'uuid-a')
52
- bridge.connectSession('b', 'uuid-b')
53
- bridge.destroy()
54
- expect(bridge.hasSession('a')).toBe(false)
55
- expect(bridge.hasSession('b')).toBe(false)
56
- })
57
- })
58
-
59
- // ---------------------------------------------------------------------------
60
- // Integration: per-chat handler serialization
61
- //
62
- // Reproduces the feishu text→tool→text race: a slow handler on msg 1 must
63
- // complete BEFORE msg 2's handler starts, otherwise msg 2 reads the stale
64
- // state msg 1's continuation is about to clear.
65
- // ---------------------------------------------------------------------------
66
-
67
- describe('WsBridge: handler serialization', () => {
68
- let server: WebSocketServer
69
- let port: number
70
- let connections: WsServerSocket[]
71
- let serverUrl: string
72
-
73
- beforeEach(async () => {
74
- connections = []
75
- // port 0 → let the OS pick a free one
76
- server = new WebSocketServer({ port: 0 })
77
- server.on('connection', (ws) => {
78
- connections.push(ws)
79
- })
80
- await new Promise<void>((resolve) => server.on('listening', () => resolve()))
81
- port = (server.address() as { port: number }).port
82
- serverUrl = `ws://127.0.0.1:${port}`
83
- })
84
-
85
- afterEach(async () => {
86
- // Forcibly kill any server-side sockets (not graceful close) so
87
- // WebSocketServer.close() doesn't wait for client FIN.
88
- for (const ws of connections) {
89
- try { ws.terminate() } catch {}
90
- }
91
- await new Promise<void>((resolve) => {
92
- const t = setTimeout(() => resolve(), 500) // hard cap
93
- server.close(() => {
94
- clearTimeout(t)
95
- resolve()
96
- })
97
- })
98
- })
99
-
100
- it('processes handler calls in strict FIFO order per chatId', async () => {
101
- const bridge = new WsBridge(serverUrl, 'test')
102
- const events: string[] = []
103
-
104
- // The handler simulates an async side effect that takes varying time.
105
- // If handlers ran concurrently, fast msgs could finish before slow ones,
106
- // producing an out-of-order `events` array.
107
- bridge.onServerMessage('chat-1', async (msg: any) => {
108
- const tag = msg.tag as string
109
- const delay = msg.delay as number
110
- events.push(`start:${tag}`)
111
- await new Promise((r) => setTimeout(r, delay))
112
- events.push(`end:${tag}`)
113
- })
114
-
115
- bridge.connectSession('chat-1', 'sess-1')
116
- const ok = await bridge.waitForOpen('chat-1')
117
- expect(ok).toBe(true)
118
- expect(connections.length).toBe(1)
119
- const serverWs = connections[0]!
120
-
121
- // Blast three messages back-to-back. msg1 is slow, msg2/msg3 are fast.
122
- // With serialization: start:1, end:1, start:2, end:2, start:3, end:3
123
- // Without serialization: start:1, start:2, start:3, end:2, end:3, end:1
124
- serverWs.send(JSON.stringify({ tag: '1', delay: 40 }))
125
- serverWs.send(JSON.stringify({ tag: '2', delay: 5 }))
126
- serverWs.send(JSON.stringify({ tag: '3', delay: 5 }))
127
-
128
- // Wait long enough for all three handlers to run serially
129
- await new Promise((r) => setTimeout(r, 200))
130
-
131
- expect(events).toEqual([
132
- 'start:1', 'end:1',
133
- 'start:2', 'end:2',
134
- 'start:3', 'end:3',
135
- ])
136
-
137
- bridge.destroy()
138
- })
139
-
140
- it('handler error does not break the chain (subsequent messages still run)', async () => {
141
- const bridge = new WsBridge(serverUrl, 'test')
142
- const events: string[] = []
143
-
144
- bridge.onServerMessage('chat-err', async (msg: any) => {
145
- if (msg.throw) {
146
- events.push('throwing')
147
- throw new Error('boom')
148
- }
149
- events.push(`ok:${msg.tag}`)
150
- })
151
-
152
- bridge.connectSession('chat-err', 'sess-err')
153
- await bridge.waitForOpen('chat-err')
154
- const serverWs = connections[0]!
155
-
156
- serverWs.send(JSON.stringify({ throw: true }))
157
- serverWs.send(JSON.stringify({ tag: 'after' }))
158
-
159
- await new Promise((r) => setTimeout(r, 80))
160
-
161
- expect(events).toEqual(['throwing', 'ok:after'])
162
-
163
- bridge.destroy()
164
- })
165
-
166
- it('resetSession clears the handler chain', async () => {
167
- const bridge = new WsBridge(serverUrl, 'test')
168
- bridge.onServerMessage('chat-reset', () => {})
169
- bridge.connectSession('chat-reset', 'sess-reset')
170
- await bridge.waitForOpen('chat-reset')
171
-
172
- bridge.resetSession('chat-reset')
173
- expect(bridge.hasSession('chat-reset')).toBe(false)
174
-
175
- bridge.destroy()
176
- })
177
- })
@@ -1,52 +0,0 @@
1
- import { describe, it, expect } from 'bun:test'
2
- import {
3
- checkAttachmentLimit,
4
- IMAGE_MAX_BYTES,
5
- FILE_MAX_BYTES,
6
- IMAGE_MIME_WHITELIST,
7
- } from '../attachment-limits.js'
8
-
9
- describe('checkAttachmentLimit', () => {
10
- it('accepts a 1 MB PNG image', () => {
11
- const result = checkAttachmentLimit('image', 1024 * 1024, 'image/png')
12
- expect(result.ok).toBe(true)
13
- })
14
-
15
- it('rejects an 11 MB image as too_large', () => {
16
- const result = checkAttachmentLimit('image', 11 * 1024 * 1024, 'image/png')
17
- expect(result.ok).toBe(false)
18
- if (!result.ok) {
19
- expect(result.reason).toBe('too_large')
20
- expect(result.hint).toContain('10')
21
- }
22
- })
23
-
24
- it('rejects an unsupported image mime', () => {
25
- const result = checkAttachmentLimit('image', 500_000, 'image/svg+xml')
26
- expect(result.ok).toBe(false)
27
- if (!result.ok) expect(result.reason).toBe('unsupported_mime')
28
- })
29
-
30
- it('rejects image/heic (not supported by Claude API)', () => {
31
- const result = checkAttachmentLimit('image', 500_000, 'image/heic')
32
- expect(result.ok).toBe(false)
33
- if (!result.ok) expect(result.reason).toBe('unsupported_mime')
34
- })
35
-
36
- it('accepts a 10 MB PDF file', () => {
37
- const result = checkAttachmentLimit('file', 10 * 1024 * 1024, 'application/pdf')
38
- expect(result.ok).toBe(true)
39
- })
40
-
41
- it('rejects a 31 MB file as too_large', () => {
42
- const result = checkAttachmentLimit('file', 31 * 1024 * 1024, 'application/pdf')
43
- expect(result.ok).toBe(false)
44
- if (!result.ok) expect(result.reason).toBe('too_large')
45
- })
46
-
47
- it('exposes the limits as exports', () => {
48
- expect(IMAGE_MAX_BYTES).toBe(10 * 1024 * 1024)
49
- expect(FILE_MAX_BYTES).toBe(30 * 1024 * 1024)
50
- expect(IMAGE_MIME_WHITELIST).toContain('image/png')
51
- })
52
- })
@@ -1,108 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
2
- import * as fs from 'node:fs/promises'
3
- import * as fsSync from 'node:fs'
4
- import * as path from 'node:path'
5
- import * as os from 'node:os'
6
- import { AttachmentStore } from '../attachment-store.js'
7
-
8
- let tmpRoot: string
9
-
10
- beforeEach(async () => {
11
- tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'att-store-test-'))
12
- })
13
-
14
- afterEach(async () => {
15
- await fs.rm(tmpRoot, { recursive: true, force: true })
16
- })
17
-
18
- describe('AttachmentStore', () => {
19
- it('writes a buffer and returns the absolute path', async () => {
20
- const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
21
- const target = store.resolvePath('feishu', 'sess-1', 'hello.png')
22
- const written = await store.write(target, Buffer.from('PNGDATA'))
23
- expect(path.isAbsolute(written)).toBe(true)
24
- const content = await fs.readFile(written)
25
- expect(content.toString()).toBe('PNGDATA')
26
- })
27
-
28
- it('writes under {root}/{platform}/{sessionId}/', async () => {
29
- const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
30
- const target = store.resolvePath('telegram', 'sess-42', 'foo.pdf')
31
- expect(target).toContain(path.join('telegram', 'sess-42'))
32
- expect(target.endsWith('foo.pdf')).toBe(true)
33
- })
34
-
35
- it('sanitizes unsafe filenames (strips path separators and ..)', async () => {
36
- const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
37
- const target = store.resolvePath('feishu', 'sess-1', '../../etc/passwd')
38
- // The resulting target must still live inside the store root.
39
- const root = path.resolve(tmpRoot)
40
- expect(path.resolve(target).startsWith(root)).toBe(true)
41
- expect(path.basename(target)).not.toContain('..')
42
- expect(path.basename(target)).not.toContain('/')
43
- })
44
-
45
- it('collapses name collisions by prefixing timestamps', async () => {
46
- const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
47
- const a = store.resolvePath('feishu', 'sess-1', 'image.png')
48
- await store.write(a, Buffer.from('first'))
49
- const b = store.resolvePath('feishu', 'sess-1', 'image.png')
50
- expect(b).not.toBe(a)
51
- await store.write(b, Buffer.from('second'))
52
- const contentB = await fs.readFile(b)
53
- expect(contentB.toString()).toBe('second')
54
- })
55
-
56
- it('gc() removes files older than retentionMs and reports counts', async () => {
57
- const store = new AttachmentStore({ root: tmpRoot, retentionMs: 50 })
58
- const target = store.resolvePath('feishu', 'sess-1', 'stale.png')
59
- await store.write(target, Buffer.from('STALE'))
60
- // Age the file manually
61
- const past = new Date(Date.now() - 10_000)
62
- await fs.utimes(target, past, past)
63
- const result = await store.gc()
64
- expect(result.removed).toBe(1)
65
- expect(result.bytes).toBe(5)
66
- await expect(fs.access(target)).rejects.toThrow()
67
- })
68
-
69
- it('gc() keeps fresh files', async () => {
70
- const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
71
- const target = store.resolvePath('feishu', 'sess-1', 'fresh.png')
72
- await store.write(target, Buffer.from('FRESH'))
73
- const result = await store.gc()
74
- expect(result.removed).toBe(0)
75
- await fs.access(target)
76
- })
77
-
78
- it('resolvePath under heavy collision pressure returns unique paths', () => {
79
- const store = new AttachmentStore({ root: tmpRoot, retentionMs: 60_000 })
80
- // First create a file so subsequent resolves hit the collision branch
81
- const a = store.resolvePath('feishu', 'sess-1', 'race.png')
82
- fsSync.writeFileSync(a, 'first')
83
- // Collect 50 resolved paths in a tight loop — none should clash
84
- const seen = new Set<string>()
85
- for (let i = 0; i < 50; i++) {
86
- seen.add(store.resolvePath('feishu', 'sess-1', 'race.png'))
87
- }
88
- expect(seen.size).toBe(50)
89
- for (const p of seen) {
90
- expect(p).not.toBe(a)
91
- }
92
- })
93
-
94
- it('gc() cleans orphan .part files after a short grace period', async () => {
95
- const store = new AttachmentStore({ root: tmpRoot, retentionMs: 10_000, orphanGraceMs: 50 })
96
- // Simulate a crashed write — leave a .part tmp file behind
97
- const dir = path.join(tmpRoot, 'feishu', 'sess-1')
98
- await fs.mkdir(dir, { recursive: true })
99
- const orphan = path.join(dir, 'image.png.1234.5678.part')
100
- await fs.writeFile(orphan, 'ORPHAN')
101
- // Age the orphan so gc considers it stale
102
- const past = new Date(Date.now() - 1000)
103
- await fs.utimes(orphan, past, past)
104
- const result = await store.gc()
105
- expect(result.removed).toBeGreaterThanOrEqual(1)
106
- await expect(fs.access(orphan)).rejects.toThrow()
107
- })
108
- })
@@ -1,115 +0,0 @@
1
- import { describe, it, expect } from 'bun:test'
2
- import { ImageBlockWatcher } from '../image-block-watcher.js'
3
-
4
- describe('ImageBlockWatcher', () => {
5
- it('extracts a markdown image with http URL', () => {
6
- const w = new ImageBlockWatcher()
7
- const out = w.feed('Here is ![alt](https://example.com/foo.png) an image.')
8
- expect(out.length).toBe(1)
9
- const source = out[0]!.source
10
- expect(source.kind).toBe('url')
11
- if (source.kind === 'url') {
12
- expect(source.url).toBe('https://example.com/foo.png')
13
- }
14
- expect(out[0]!.alt).toBe('alt')
15
- })
16
-
17
- it('extracts a markdown image with absolute local path', () => {
18
- const w = new ImageBlockWatcher()
19
- const out = w.feed('![cat](/tmp/cat.jpg)')
20
- expect(out.length).toBe(1)
21
- const source = out[0]!.source
22
- expect(source.kind).toBe('path')
23
- if (source.kind === 'path') {
24
- expect(source.path).toBe('/tmp/cat.jpg')
25
- }
26
- })
27
-
28
- it('extracts a markdown image with file:// URL as path', () => {
29
- const w = new ImageBlockWatcher()
30
- const out = w.feed('![x](file:///var/img/x.png)')
31
- const source = out[0]!.source
32
- expect(source.kind).toBe('path')
33
- if (source.kind === 'path') expect(source.path).toBe('/var/img/x.png')
34
- })
35
-
36
- it('extracts a data URI as base64', () => {
37
- const w = new ImageBlockWatcher()
38
- const out = w.feed('![inline](data:image/png;base64,AAAA)')
39
- const source = out[0]!.source
40
- expect(source.kind).toBe('base64')
41
- if (source.kind === 'base64') {
42
- expect(source.mime).toBe('image/png')
43
- expect(source.data).toBe('AAAA')
44
- }
45
- })
46
-
47
- it('deduplicates the same image across multiple feeds', () => {
48
- const w = new ImageBlockWatcher()
49
- const a = w.feed('![](https://x/y.png)')
50
- const b = w.feed(' repeated ![](https://x/y.png) again')
51
- expect(a.length).toBe(1)
52
- expect(b.length).toBe(0)
53
- })
54
-
55
- it('handles images split across feed boundaries', () => {
56
- const w = new ImageBlockWatcher()
57
- const a = w.feed('a ![al')
58
- const b = w.feed('t](/tmp/x.png) b')
59
- expect(a.length).toBe(0)
60
- expect(b.length).toBe(1)
61
- const source = b[0]!.source
62
- expect(source.kind).toBe('path')
63
- if (source.kind === 'path') expect(source.path).toBe('/tmp/x.png')
64
- })
65
-
66
- it('skips non-image markdown links', () => {
67
- const w = new ImageBlockWatcher()
68
- const out = w.feed('See [docs](https://example.com).')
69
- expect(out.length).toBe(0)
70
- })
71
-
72
- it('drain() returns all accumulated uploads', () => {
73
- const w = new ImageBlockWatcher()
74
- w.feed('![a](/tmp/a.png)')
75
- w.feed(' and ![b](/tmp/b.png)')
76
- const all = w.drain()
77
- expect(all.length).toBe(2)
78
- })
79
-
80
- it('reset() clears buffer, seen set, and accumulated list', () => {
81
- const w = new ImageBlockWatcher()
82
- w.feed('![a](/tmp/a.png)')
83
- w.reset()
84
- // After reset, drain() is empty
85
- expect(w.drain().length).toBe(0)
86
- // And re-feeding the same image yields a fresh emit (dedup state cleared)
87
- const out = w.feed('![a](/tmp/a.png)')
88
- expect(out.length).toBe(1)
89
- })
90
-
91
- it('skips relative paths (cannot be resolved safely)', () => {
92
- const w = new ImageBlockWatcher()
93
- const out = w.feed('![rel](relative/path.png) and ![ok](/tmp/ok.png)')
94
- expect(out.length).toBe(1)
95
- const source = out[0]!.source
96
- expect(source.kind).toBe('path')
97
- if (source.kind === 'path') expect(source.path).toBe('/tmp/ok.png')
98
- })
99
-
100
- it('extracts multiple images from a single feed chunk in order', () => {
101
- const w = new ImageBlockWatcher()
102
- const out = w.feed('![a](/tmp/a.png) ![b](https://x/b.png) ![c](data:image/png;base64,QQ==)')
103
- expect(out.length).toBe(3)
104
- expect(out[0]!.source.kind).toBe('path')
105
- expect(out[1]!.source.kind).toBe('url')
106
- expect(out[2]!.source.kind).toBe('base64')
107
- })
108
-
109
- it('rejects malformed data URI (not base64)', () => {
110
- const w = new ImageBlockWatcher()
111
- const out = w.feed('![bad](data:image/png,ABC)')
112
- // Not in `;base64,` form → classify returns null → skipped
113
- expect(out.length).toBe(0)
114
- })
115
- })
@@ -1,194 +0,0 @@
1
- /**
2
- * card-errors 单元测试
3
- */
4
-
5
- import { describe, it, expect } from 'bun:test'
6
- import {
7
- CARD_ERROR,
8
- CARD_CONTENT_SUB_ERROR,
9
- extractLarkApiCode,
10
- extractSubCode,
11
- parseCardApiError,
12
- isCardRateLimitError,
13
- isCardTableLimitError,
14
- } from '../card-errors.js'
15
-
16
- describe('extractLarkApiCode', () => {
17
- it('从 err.code 直接提取', () => {
18
- expect(extractLarkApiCode({ code: 230020 })).toBe(230020)
19
- })
20
-
21
- it('从 err.data.code 提取', () => {
22
- expect(extractLarkApiCode({ data: { code: 230099 } })).toBe(230099)
23
- })
24
-
25
- it('从 err.response.data.code 提取(Axios 风格)', () => {
26
- expect(extractLarkApiCode({ response: { data: { code: 99991672 } } })).toBe(99991672)
27
- })
28
-
29
- it('数字字符串被强制转成 number', () => {
30
- expect(extractLarkApiCode({ code: '230020' })).toBe(230020)
31
- })
32
-
33
- it('三层结构优先级: err.code > err.data.code > err.response.data.code', () => {
34
- const err = {
35
- code: 1,
36
- data: { code: 2 },
37
- response: { data: { code: 3 } },
38
- }
39
- expect(extractLarkApiCode(err)).toBe(1)
40
- })
41
-
42
- it('none → undefined', () => {
43
- expect(extractLarkApiCode({})).toBeUndefined()
44
- expect(extractLarkApiCode(null)).toBeUndefined()
45
- expect(extractLarkApiCode(undefined)).toBeUndefined()
46
- expect(extractLarkApiCode('just a string')).toBeUndefined()
47
- expect(extractLarkApiCode(new Error('plain'))).toBeUndefined()
48
- })
49
-
50
- it('非有限数字被忽略', () => {
51
- expect(extractLarkApiCode({ code: NaN })).toBeUndefined()
52
- expect(extractLarkApiCode({ code: 'not-a-number' })).toBeUndefined()
53
- })
54
- })
55
-
56
- describe('extractSubCode', () => {
57
- it('识别标准的 ErrCode: 11310', () => {
58
- const msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit'
59
- expect(extractSubCode(msg)).toBe(11310)
60
- })
61
-
62
- it('无 ErrCode 时返回 null', () => {
63
- expect(extractSubCode('random error message')).toBeNull()
64
- expect(extractSubCode('')).toBeNull()
65
- })
66
-
67
- it('ErrCode 大小写容错(冒号后多空格)', () => {
68
- expect(extractSubCode('ErrCode: 42')).toBe(42)
69
- })
70
- })
71
-
72
- describe('parseCardApiError', () => {
73
- it('从 SDK 风格错误提取完整结构', () => {
74
- const err = { code: 230099, msg: 'ErrCode: 11310; ErrMsg: card table number over limit' }
75
- const parsed = parseCardApiError(err)
76
- expect(parsed).toEqual({
77
- code: 230099,
78
- subCode: 11310,
79
- errMsg: 'ErrCode: 11310; ErrMsg: card table number over limit',
80
- })
81
- })
82
-
83
- it('从 Axios 风格错误(response.data.msg)提取', () => {
84
- const err = {
85
- response: {
86
- data: {
87
- code: 230020,
88
- msg: 'rate limited',
89
- },
90
- },
91
- }
92
- const parsed = parseCardApiError(err)
93
- expect(parsed?.code).toBe(230020)
94
- expect(parsed?.errMsg).toBe('rate limited')
95
- expect(parsed?.subCode).toBeNull()
96
- })
97
-
98
- it('无 code 时返回 null', () => {
99
- expect(parseCardApiError({})).toBeNull()
100
- expect(parseCardApiError(null)).toBeNull()
101
- expect(parseCardApiError('string')).toBeNull()
102
- })
103
-
104
- it('有 code 无 msg 时 errMsg 为空字符串', () => {
105
- const parsed = parseCardApiError({ code: 230020 })
106
- expect(parsed).toEqual({ code: 230020, subCode: null, errMsg: '' })
107
- })
108
-
109
- it('fallback 到 err.message', () => {
110
- const err = Object.assign(new Error('fallback text'), { code: 230099 })
111
- const parsed = parseCardApiError(err)
112
- expect(parsed?.errMsg).toBe('fallback text')
113
- })
114
- })
115
-
116
- describe('isCardRateLimitError', () => {
117
- it('识别 230020', () => {
118
- expect(isCardRateLimitError({ code: 230020 })).toBe(true)
119
- })
120
-
121
- it('识别 Axios 风格 230020', () => {
122
- expect(isCardRateLimitError({ response: { data: { code: 230020 } } })).toBe(true)
123
- })
124
-
125
- it('不匹配其他 code', () => {
126
- expect(isCardRateLimitError({ code: 230099 })).toBe(false)
127
- expect(isCardRateLimitError({ code: 99991672 })).toBe(false)
128
- })
129
-
130
- it('非错误对象返回 false', () => {
131
- expect(isCardRateLimitError(null)).toBe(false)
132
- expect(isCardRateLimitError({})).toBe(false)
133
- expect(isCardRateLimitError(new Error('random'))).toBe(false)
134
- })
135
- })
136
-
137
- describe('isCardTableLimitError', () => {
138
- const validMsg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; '
139
-
140
- it('严格三条件匹配: code=230099 + subCode=11310 + msg 含 table number over limit', () => {
141
- const err = { code: CARD_ERROR.CARD_CONTENT_FAILED, msg: validMsg }
142
- expect(isCardTableLimitError(err)).toBe(true)
143
- })
144
-
145
- it('从 Axios 风格的 response.data 匹配', () => {
146
- const err = {
147
- response: {
148
- data: {
149
- code: 230099,
150
- msg: validMsg,
151
- },
152
- },
153
- }
154
- expect(isCardTableLimitError(err)).toBe(true)
155
- })
156
-
157
- it('230099 + 11310 但没有 "table number over limit" 字样 → false(其它元素超限)', () => {
158
- const err = {
159
- code: CARD_ERROR.CARD_CONTENT_FAILED,
160
- msg: 'ErrCode: 11310; ErrMsg: some other element limit; ',
161
- }
162
- expect(isCardTableLimitError(err)).toBe(false)
163
- })
164
-
165
- it('code 不是 230099 → false', () => {
166
- const err = { code: 230020, msg: validMsg }
167
- expect(isCardTableLimitError(err)).toBe(false)
168
- })
169
-
170
- it('没有 subCode → false', () => {
171
- const err = { code: 230099, msg: 'card table number over limit (no ErrCode)' }
172
- expect(isCardTableLimitError(err)).toBe(false)
173
- })
174
-
175
- it('不区分 "table number" 的大小写', () => {
176
- const err = {
177
- code: 230099,
178
- msg: 'ErrCode: 11310; ErrMsg: CARD TABLE NUMBER OVER LIMIT; ',
179
- }
180
- expect(isCardTableLimitError(err)).toBe(true)
181
- })
182
- })
183
-
184
- describe('常量值', () => {
185
- it('CARD_ERROR.RATE_LIMITED === 230020', () => {
186
- expect(CARD_ERROR.RATE_LIMITED).toBe(230020)
187
- })
188
- it('CARD_ERROR.CARD_CONTENT_FAILED === 230099', () => {
189
- expect(CARD_ERROR.CARD_CONTENT_FAILED).toBe(230099)
190
- })
191
- it('CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT === 11310', () => {
192
- expect(CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT).toBe(11310)
193
- })
194
- })