chore: consolidate heicode workspace into single repository
Import cc-haha and new-api into a single repository layout for unified delivery. Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* cardkit.ts 单元测试
|
||||
*
|
||||
* 不调用真实的 Lark API —— 用 mock client 捕获调用参数,验证:
|
||||
* - 每个函数构造的 payload 结构
|
||||
* - 非零 code 响应抛出 CardKitApiError(可被 card-errors 识别)
|
||||
* - 缺失关键字段时抛错
|
||||
* - sequence 正确传递
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test'
|
||||
import {
|
||||
createCardEntity,
|
||||
sendCardAsMessage,
|
||||
streamCardContent,
|
||||
setCardStreamingMode,
|
||||
updateCardKitCard,
|
||||
CardKitApiError,
|
||||
STREAMING_ELEMENT_ID,
|
||||
} from '../cardkit.js'
|
||||
import { isCardRateLimitError, isCardTableLimitError } from '../card-errors.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock client factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type MockCall = { api: string; args: any }
|
||||
|
||||
function makeMockClient(responses: Record<string, any>) {
|
||||
const calls: MockCall[] = []
|
||||
const recorder = (api: string, resp: any) => async (args: any) => {
|
||||
calls.push({ api, args })
|
||||
if (typeof resp === 'function') {
|
||||
return resp(args)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
const client: any = {
|
||||
cardkit: {
|
||||
v1: {
|
||||
card: {
|
||||
create: recorder('cardkit.v1.card.create', responses['card.create']),
|
||||
settings: recorder('cardkit.v1.card.settings', responses['card.settings']),
|
||||
update: recorder('cardkit.v1.card.update', responses['card.update']),
|
||||
},
|
||||
cardElement: {
|
||||
content: recorder(
|
||||
'cardkit.v1.cardElement.content',
|
||||
responses['cardElement.content'],
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
im: {
|
||||
message: {
|
||||
create: recorder('im.message.create', responses['im.message.create']),
|
||||
reply: recorder('im.message.reply', responses['im.message.reply']),
|
||||
},
|
||||
},
|
||||
}
|
||||
return { client, calls }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// createCardEntity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('createCardEntity', () => {
|
||||
it('构造 card_json payload 并返回 card_id', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.create': {
|
||||
code: 0,
|
||||
data: { card_id: 'ck_abc_123' },
|
||||
},
|
||||
})
|
||||
const card = { schema: '2.0', body: { elements: [] } }
|
||||
const id = await createCardEntity(client, card)
|
||||
|
||||
expect(id).toBe('ck_abc_123')
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.card.create')
|
||||
expect(calls[0]!.args.data.type).toBe('card_json')
|
||||
// data.data 应当是 card 的 JSON 字符串
|
||||
expect(calls[0]!.args.data.data).toBe(JSON.stringify(card))
|
||||
})
|
||||
|
||||
it('兼容顶层 card_id(某些 SDK 包装层)', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, card_id: 'top_level_id' },
|
||||
})
|
||||
const id = await createCardEntity(client, {})
|
||||
expect(id).toBe('top_level_id')
|
||||
})
|
||||
|
||||
it('non-zero code 抛 CardKitApiError', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 230099, msg: 'something failed' },
|
||||
})
|
||||
await expect(createCardEntity(client, {})).rejects.toThrow(CardKitApiError)
|
||||
})
|
||||
|
||||
it('code=0 但缺 card_id 抛错', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.create': { code: 0, data: {} },
|
||||
})
|
||||
await expect(createCardEntity(client, {})).rejects.toThrow(/missing card_id/)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sendCardAsMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('sendCardAsMessage', () => {
|
||||
it('无 replyTo: 走 im.message.create 使用 chat_id', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'im.message.create': { data: { message_id: 'om_new_msg_1' } },
|
||||
})
|
||||
const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz')
|
||||
expect(mid).toBe('om_new_msg_1')
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('im.message.create')
|
||||
expect(calls[0]!.args.params.receive_id_type).toBe('chat_id')
|
||||
expect(calls[0]!.args.data.receive_id).toBe('oc_chat_123')
|
||||
expect(calls[0]!.args.data.msg_type).toBe('interactive')
|
||||
// content 格式: {"type":"card","data":{"card_id":"xxx"}}
|
||||
const parsed = JSON.parse(calls[0]!.args.data.content)
|
||||
expect(parsed).toEqual({ type: 'card', data: { card_id: 'ck_id_xyz' } })
|
||||
})
|
||||
|
||||
it('有 replyTo: 走 im.message.reply', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'im.message.reply': { data: { message_id: 'om_reply_1' } },
|
||||
})
|
||||
const mid = await sendCardAsMessage(client, 'oc_chat_123', 'ck_id_xyz', 'om_parent')
|
||||
expect(mid).toBe('om_reply_1')
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('im.message.reply')
|
||||
expect(calls[0]!.args.path.message_id).toBe('om_parent')
|
||||
const parsed = JSON.parse(calls[0]!.args.data.content)
|
||||
expect(parsed.data.card_id).toBe('ck_id_xyz')
|
||||
})
|
||||
|
||||
it('缺 message_id 抛错', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'im.message.create': { data: {} },
|
||||
})
|
||||
await expect(sendCardAsMessage(client, 'c', 'ck')).rejects.toThrow(
|
||||
/missing message_id/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// streamCardContent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('streamCardContent', () => {
|
||||
it('构造 content + sequence payload,path 包含 card_id + element_id', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'cardElement.content': { code: 0 },
|
||||
})
|
||||
await streamCardContent(client, 'ck_abc', STREAMING_ELEMENT_ID, 'hello', 42)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.cardElement.content')
|
||||
expect(calls[0]!.args.data).toEqual({ content: 'hello', sequence: 42 })
|
||||
expect(calls[0]!.args.path).toEqual({
|
||||
card_id: 'ck_abc',
|
||||
element_id: STREAMING_ELEMENT_ID,
|
||||
})
|
||||
})
|
||||
|
||||
it('STREAMING_ELEMENT_ID 常量 = "streaming_content"', () => {
|
||||
expect(STREAMING_ELEMENT_ID).toBe('streaming_content')
|
||||
})
|
||||
|
||||
it('230020 响应可被 isCardRateLimitError 识别', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'cardElement.content': { code: 230020, msg: 'rate limited' },
|
||||
})
|
||||
try {
|
||||
await streamCardContent(client, 'ck', 'el', 'x', 1)
|
||||
expect('should have thrown').toBe('but did not')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CardKitApiError)
|
||||
expect(isCardRateLimitError(err)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('230099 + table limit msg 可被 isCardTableLimitError 识别', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'cardElement.content': {
|
||||
code: 230099,
|
||||
msg: 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; ',
|
||||
},
|
||||
})
|
||||
try {
|
||||
await streamCardContent(client, 'ck', 'el', 'x', 1)
|
||||
expect('should have thrown').toBe('but did not')
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(CardKitApiError)
|
||||
expect(isCardTableLimitError(err)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setCardStreamingMode
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('setCardStreamingMode', () => {
|
||||
it('streaming_mode=false + sequence 正确传递', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.settings': { code: 0 },
|
||||
})
|
||||
await setCardStreamingMode(client, 'ck_xxx', false, 99)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.card.settings')
|
||||
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_xxx' })
|
||||
expect(calls[0]!.args.data.sequence).toBe(99)
|
||||
// settings 是 JSON 字符串
|
||||
const settings = JSON.parse(calls[0]!.args.data.settings)
|
||||
expect(settings).toEqual({ streaming_mode: false })
|
||||
})
|
||||
|
||||
it('streaming_mode=true 也能工作', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.settings': { code: 0 },
|
||||
})
|
||||
await setCardStreamingMode(client, 'ck', true, 1)
|
||||
const settings = JSON.parse(calls[0]!.args.data.settings)
|
||||
expect(settings).toEqual({ streaming_mode: true })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// updateCardKitCard
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('updateCardKitCard', () => {
|
||||
it('把 card 包装成 card_json payload + sequence', async () => {
|
||||
const { client, calls } = makeMockClient({
|
||||
'card.update': { code: 0 },
|
||||
})
|
||||
const card = { schema: '2.0', body: { elements: [{ tag: 'markdown', content: 'done' }] } }
|
||||
await updateCardKitCard(client, 'ck_final', card, 100)
|
||||
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.api).toBe('cardkit.v1.card.update')
|
||||
expect(calls[0]!.args.path).toEqual({ card_id: 'ck_final' })
|
||||
expect(calls[0]!.args.data.sequence).toBe(100)
|
||||
expect(calls[0]!.args.data.card.type).toBe('card_json')
|
||||
expect(calls[0]!.args.data.card.data).toBe(JSON.stringify(card))
|
||||
})
|
||||
|
||||
it('非零 code 抛 CardKitApiError', async () => {
|
||||
const { client } = makeMockClient({
|
||||
'card.update': { code: -1, msg: 'bad card' },
|
||||
})
|
||||
await expect(updateCardKitCard(client, 'ck', {}, 1)).rejects.toThrow(CardKitApiError)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CardKitApiError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('CardKitApiError', () => {
|
||||
it('携带 code 和 msg,可被 parseCardApiError 识别', () => {
|
||||
const err = new CardKitApiError({
|
||||
api: 'card.update',
|
||||
code: 230020,
|
||||
msg: 'rate limited',
|
||||
context: 'seq=5',
|
||||
})
|
||||
expect(err.code).toBe(230020)
|
||||
expect(err.msg).toBe('rate limited')
|
||||
expect(err.name).toBe('CardKitApiError')
|
||||
expect(isCardRateLimitError(err)).toBe(true)
|
||||
})
|
||||
|
||||
it('消息包含 api 名和 context', () => {
|
||||
const err = new CardKitApiError({
|
||||
api: 'cardElement.content',
|
||||
code: 230099,
|
||||
msg: 'oops',
|
||||
context: 'seq=3 len=100',
|
||||
})
|
||||
expect(err.message).toContain('cardElement.content')
|
||||
expect(err.message).toContain('230099')
|
||||
expect(err.message).toContain('seq=3 len=100')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user