Files
WechatExplorer/tests/integration/scheduled-report-api.test.ts
T
电摇小子 4d5c6459fe feat: 定时日报并入自动化,统一规则管理与发送链路
发送目标与退群通知统一为 来源群 / 自己 / 文件传输助手 / 指定好友
日报页补「定时日报已并入自动化」指引条
2026-09-28 17:11:50 +07:00

293 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, it, vi } from 'vitest'
const fixture = vi.hoisted(() => ({
capability: {
supported: true,
ready: true,
// 显式标注成联合类型:否则会被收窄成字面量 'ready',
// 后面把它改成 'needs_binding' 时就不合法了。
status: 'ready' as PersonalWechatSendCapabilityState,
capabilities: { text: true, image: true, voice: true },
senderStatus: {} as never,
message: '个人微信已准备好发送日报'
},
contacts: [
{
md5: 'tech-md5',
m_nsUsrName: 'tech@chatroom',
m_nsNickName: '技术交流群',
type: 'group' as const
},
{
md5: 'product-md5',
m_nsUsrName: 'product@chatroom',
m_nsNickName: '产品交流群',
type: 'group' as const
}
]
}))
vi.mock('electron', () => ({
app: { getPath: () => '/tmp/tracememo-scheduled-report-api' },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (value: string) => Buffer.from(value),
decryptString: (value: Buffer) => value.toString()
}
}))
vi.mock('../../src/main/services/chat-service', () => ({
isReady: () => true,
listContacts: () => fixture.contacts,
listMessages: () => [],
getGroupSnapshot: () => null,
listRecentChat: () => [],
resolveMd5: () => null
}))
vi.mock('../../src/main/services/personal-wechat-capability-service', () => ({
personalWechatCapabilityService: {
getPersonalWechatSendCapability: vi.fn(async () => fixture.capability)
}
}))
vi.mock('../../src/main/services/scheduled-report-service', () => ({ scheduledReportService: {} }))
vi.mock('../../src/main/services/agent-group-report-service', () => ({
generateAgentGroupReport: vi.fn(async () => ({ success: true }))
}))
vi.mock('../../src/main/group-report-service', () => ({
exportGroupReport: vi.fn(async () => ({ success: true }))
}))
vi.mock('../../src/main/services/agent-hub-service', () => ({
agentHubService: { getStatus: () => ({}), testSend: vi.fn(async () => ({ success: true })) }
}))
import { startHttpServer, type HttpServerHandle } from '../../src/main/http-server'
import type {
ScheduledReportCreateInput,
ScheduledReportExecution,
ScheduledReportResult,
ScheduledReportTask,
ScheduledReportUpdateInput
} from '../../src/shared/scheduled-report'
import type { PersonalWechatSendCapabilityState } from '../../src/shared/personal-wechat'
import type { ScheduledReportApiDependencies } from '../../src/main/services/scheduled-report-api-service'
const TOKEN = 'A'.repeat(43)
const handles: HttpServerHandle[] = []
const originalContacts = fixture.contacts
const makeTask = (overrides: Partial<ScheduledReportTask> = {}): ScheduledReportTask => ({
id: 'task-1',
name: '技术交流群 · 每日日报',
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:00',
reportRange: 'yesterday',
enabled: true,
createdAt: '2026-08-27T00:00:00.000Z',
updatedAt: '2026-08-27T00:00:00.000Z',
nextRunAt: '2026-08-28T01:00:00.000Z',
...overrides
})
function baseUrl(handle: HttpServerHandle): string {
return `http://${handle.host}:${handle.port}`
}
async function startFixture(
options: { tasks?: ScheduledReportTask[] } = {}
): Promise<{ handle: HttpServerHandle; service: ScheduledReportApiDependencies['service'] }> {
const tasks = options.tasks || []
const executions: ScheduledReportExecution[] = []
const service: ScheduledReportApiDependencies['service'] = {
listTasks: vi.fn(async (): Promise<ScheduledReportTask[]> => tasks),
listExecutions: vi.fn(
async (taskId?: string): Promise<ScheduledReportExecution[]> =>
taskId ? executions.filter((item) => item.taskId === taskId) : executions
),
createTask: vi.fn(
async (
input: ScheduledReportCreateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const created = makeTask({
id: `task-${tasks.length + 1}`,
name: String(input.name),
group: String(input.group),
target: String(input.target),
scheduleTime: String(input.scheduleTime),
reportRange: input.reportRange as ScheduledReportTask['reportRange'],
enabled: Boolean(input.enabled)
})
tasks.push(created)
return { success: true, data: created }
}
),
updateTask: vi.fn(
async (
id: string,
input: ScheduledReportUpdateInput
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const current = tasks.find((item) => item.id === id)
if (!current) return { success: false, error: '未找到定时日报任务' }
const updated = { ...current, ...input, updatedAt: '2026-08-27T02:00:00.000Z' }
tasks[tasks.indexOf(current)] = updated
return { success: true, data: updated }
}
),
deleteTask: vi.fn(
async (id: string): Promise<ScheduledReportResult<{ deletedId: string }>> => {
const index = tasks.findIndex((item) => item.id === id)
if (index < 0) return { success: false, error: '未找到定时日报任务' }
tasks.splice(index, 1)
return { success: true, data: { deletedId: id } }
}
),
setTaskEnabled: vi.fn(
async (
id: string,
enabled: boolean
): Promise<ScheduledReportResult<ScheduledReportTask>> => {
const current = tasks.find((item) => item.id === id)
if (!current) return { success: false, error: '未找到定时日报任务' }
const updated = { ...current, enabled }
tasks[tasks.indexOf(current)] = updated
return { success: true, data: updated }
}
),
runScheduledReportNow: vi.fn(
async (id: string): Promise<ScheduledReportResult<ScheduledReportExecution>> => {
const execution: ScheduledReportExecution = {
id: `execution-${executions.length + 1}`,
taskId: id,
startedAt: '2026-08-27T01:00:00.000Z',
finishedAt: '2026-08-27T01:01:00.000Z',
status: 'success',
message: '日报生成成功,微信发送成功'
}
executions.push(execution)
return { success: true, data: execution }
}
)
}
const handle = await startHttpServer('127.0.0.1', 0, {
tokenProvider: () => TOKEN,
scheduledReportService: service,
scheduledReportCapabilityProvider: async () => fixture.capability,
scheduledReportContactsProvider: () => fixture.contacts,
scheduledReportDatabaseReadyProvider: () => true,
scheduledReportPlatform: 'darwin'
})
handles.push(handle)
return { handle, service }
}
const auth = { Authorization: `Bearer ${TOKEN}` }
afterEach(async () => {
await Promise.all(handles.splice(0).map((handle) => handle.close()))
fixture.contacts = originalContacts
fixture.capability = {
...fixture.capability,
ready: true,
status: 'ready',
message: '个人微信已准备好发送日报'
}
})
describe('scheduled report Local HTTP API', () => {
it('requires authentication and exposes capability plus CRUD lifecycle', async () => {
const unauthenticated = await startFixture()
expect(
(await fetch(`${baseUrl(unauthenticated.handle)}/api/v1/scheduled-reports`)).status
).toBe(401)
const { handle, service } = await startFixture()
const create = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({
group: { name: '技术交流群' },
schedule: { type: 'daily', time: '09:00' },
reportRange: 'yesterday'
})
})
expect(create.status).toBe(201)
const created = await create.json()
expect(created).toMatchObject({ created: true, task: { group: { talker: 'tech@chatroom' } } })
const taskId = created.task.id
const list = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports`, { headers: auth })
expect(await list.json()).toMatchObject({ count: 1, tasks: [{ id: taskId }] })
const get = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}`, {
headers: auth
})
expect((await get.json()).task.id).toBe(taskId)
const update = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}`, {
method: 'PATCH',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ schedule: { type: 'daily', time: '10:00' } })
})
expect((await update.json()).task.schedule.time).toBe('10:00')
await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/disable`, {
method: 'POST',
headers: auth
})
await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/enable`, {
method: 'POST',
headers: auth
})
const run = await fetch(`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/run`, {
method: 'POST',
headers: auth
})
expect(await run.json()).toMatchObject({ success: true, execution: { status: 'success' } })
const executions = await fetch(
`${baseUrl(handle)}/api/v1/scheduled-reports/${taskId}/executions`,
{ headers: auth }
)
expect((await executions.json()).executions).toHaveLength(1)
const capability = await fetch(`${baseUrl(handle)}/api/v1/wechat-personal/send-capability`, {
headers: auth
})
expect(await capability.json()).toMatchObject({ capability: { status: 'ready' } })
expect(service.createTask).toHaveBeenCalledOnce()
// HTTP 契约把群解析成**会话 md5**、目标解析成 roomId 后交给服务层
//(服务层再把这些标识收敛成稳定会话 id)。
expect(vi.mocked(service.createTask).mock.calls[0][0]).toMatchObject({
group: 'tech-md5',
target: 'tech@chatroom',
scheduleTime: '09:00'
})
})
it('allows unavailable sending capability while still rejecting ambiguous groups', async () => {
fixture.capability = {
...fixture.capability,
ready: false,
status: 'needs_binding',
message: '请先绑定个人微信'
}
const first = await startFixture()
const createdWhileUnavailable = await fetch(
`${baseUrl(first.handle)}/api/v1/scheduled-reports`,
{
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ group: '技术交流群', schedule: { type: 'daily', time: '09:00' } })
}
)
expect(createdWhileUnavailable.status).toBe(201)
expect(await createdWhileUnavailable.json()).toMatchObject({ created: true })
fixture.capability = { ...fixture.capability, ready: true, status: 'ready' }
fixture.contacts = fixture.contacts.map((contact) => ({ ...contact, m_nsNickName: '重复群' }))
const ambiguous = await startFixture()
const ambiguousResponse = await fetch(`${baseUrl(ambiguous.handle)}/api/v1/scheduled-reports`, {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ group: '重复群', schedule: { type: 'daily', time: '09:00' } })
})
expect(ambiguousResponse.status).toBe(409)
expect(await ambiguousResponse.json()).toMatchObject({ error: 'group_ambiguous' })
})
})