349 lines
12 KiB
TypeScript
349 lines
12 KiB
TypeScript
const mockCreateMeetingWorkspace = jest.fn()
|
|
const mockGetMeetingDetail = jest.fn()
|
|
const mockCreateMeetingMemo = jest.fn()
|
|
const mockSearchKnowledge = jest.fn()
|
|
|
|
jest.mock('../src/features/meetings/meetings-service', () => {
|
|
class MeetingServiceError extends Error {
|
|
public readonly code: string
|
|
|
|
constructor(errorCode: string, message?: string) {
|
|
super(message ?? errorCode)
|
|
this.code = errorCode
|
|
this.name = 'MeetingServiceError'
|
|
}
|
|
}
|
|
return {
|
|
createMeetingWorkspace: (...args: unknown[]) => mockCreateMeetingWorkspace(...args),
|
|
getMeetingDetail: (...args: unknown[]) => mockGetMeetingDetail(...args),
|
|
createMeetingMemo: (...args: unknown[]) => mockCreateMeetingMemo(...args),
|
|
MeetingServiceError,
|
|
}
|
|
})
|
|
|
|
jest.mock('../src/features/knowledge/knowledge-service', () => {
|
|
class KnowledgeServiceError extends Error {
|
|
public readonly code: string
|
|
public readonly retryable: boolean
|
|
public readonly status: number | null
|
|
|
|
constructor(
|
|
errorCode: string,
|
|
retryable: boolean,
|
|
status: number | null = null,
|
|
) {
|
|
super(errorCode)
|
|
this.code = errorCode
|
|
this.retryable = retryable
|
|
this.status = status
|
|
this.name = 'KnowledgeServiceError'
|
|
}
|
|
}
|
|
return {
|
|
searchKnowledge: (...args: unknown[]) => mockSearchKnowledge(...args),
|
|
KnowledgeServiceError,
|
|
}
|
|
})
|
|
|
|
import AsyncStorage from '@react-native-async-storage/async-storage'
|
|
import {
|
|
ActionServiceError,
|
|
executeConfirmedAction,
|
|
listActionHistory,
|
|
parseActionCommand,
|
|
parseActionJson,
|
|
} from '../src/features/actions/action-service'
|
|
|
|
const USER_ID = '11111111-1111-4111-8111-111111111111'
|
|
const MEETING_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
|
|
const MEMO_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'
|
|
|
|
beforeEach(async () => {
|
|
jest.restoreAllMocks()
|
|
mockCreateMeetingWorkspace.mockReset()
|
|
mockGetMeetingDetail.mockReset()
|
|
mockCreateMeetingMemo.mockReset()
|
|
mockSearchKnowledge.mockReset()
|
|
await AsyncStorage.clear()
|
|
})
|
|
|
|
describe('action parser contract', () => {
|
|
it('accepts only strictly validated allowlisted JSON', () => {
|
|
expect(parseActionJson(JSON.stringify({
|
|
type: 'create_meeting',
|
|
args: { title: ' Product review ' },
|
|
rationale: 'The user explicitly requested a meeting workspace.',
|
|
}))).toEqual({
|
|
type: 'create_meeting',
|
|
args: { title: 'Product review' },
|
|
rationale: 'The user explicitly requested a meeting workspace.',
|
|
executable: true,
|
|
})
|
|
|
|
expect(parseActionJson(JSON.stringify({
|
|
type: 'search_knowledge',
|
|
args: { query: 'release policy' },
|
|
rationale: 'This is a read-only knowledge search.',
|
|
}))).toMatchObject({ type: 'search_knowledge', executable: true })
|
|
})
|
|
|
|
it('blocks external invitations and unknown commands after parsing', () => {
|
|
expect(parseActionJson(JSON.stringify({
|
|
type: 'send_team_invite',
|
|
args: { team_id: MEETING_ID, email: 'member@example.org', role: 'member' },
|
|
rationale: 'The user asked for a team invitation.',
|
|
}))).toMatchObject({
|
|
type: 'send_team_invite',
|
|
executable: false,
|
|
blockReason: 'external-side-effect',
|
|
})
|
|
|
|
expect(parseActionJson(JSON.stringify({
|
|
type: 'unknown',
|
|
args: {},
|
|
rationale: 'The request lacks a supported action.',
|
|
}))).toMatchObject({ executable: false, blockReason: 'unknown-action' })
|
|
})
|
|
|
|
it('rejects markdown fences, prose, extra keys, and invented missing identifiers', () => {
|
|
const validObject = '{"type":"unknown","args":{},"rationale":"Unsupported"}'
|
|
expect(() => parseActionJson(`\`\`\`json\n${validObject}\n\`\`\``))
|
|
.toThrow(ActionServiceError)
|
|
expect(() => parseActionJson(`${validObject}\nThis is the result.`))
|
|
.toThrow(ActionServiceError)
|
|
expect(() => parseActionJson(JSON.stringify({
|
|
type: 'create_meeting',
|
|
args: { title: 'Review', hidden: 'side effect' },
|
|
rationale: 'Create it.',
|
|
}))).toThrow(ActionServiceError)
|
|
expect(() => parseActionJson(JSON.stringify({
|
|
type: 'create_memo',
|
|
args: { content: 'Memo without meeting id' },
|
|
rationale: 'Missing the required ID.',
|
|
}))).toThrow(ActionServiceError)
|
|
})
|
|
|
|
it('calls authenticated llm-proxy and rejects a multi-block response', async () => {
|
|
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({
|
|
content: [
|
|
{ type: 'text', text: '{"type":"unknown","args":{},"rationale":"one"}' },
|
|
{ type: 'text', text: '{"type":"unknown","args":{},"rationale":"two"}' },
|
|
],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'X-D3RO-Generation-Id': '22222222-2222-4222-8222-222222222222' },
|
|
}))
|
|
|
|
await expect(parseActionCommand('Do something', { accessToken: 'access-token' }))
|
|
.rejects.toMatchObject({ code: 'INVALID_RESPONSE' })
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
expect.stringContaining('/functions/v1/llm-proxy'),
|
|
expect.objectContaining({ method: 'POST' }),
|
|
)
|
|
const request = fetchMock.mock.calls[0][1] as RequestInit
|
|
expect((request.headers as Record<string, string>).Authorization).toBe('Bearer access-token')
|
|
expect((request.headers as Record<string, string>)['X-D3RO-Generation-Purpose'])
|
|
.toBe('action_response')
|
|
expect(JSON.parse(String(request.body))).toEqual(expect.objectContaining({
|
|
stream: false,
|
|
max_tokens: 512,
|
|
}))
|
|
})
|
|
|
|
it('returns a reportable generation receipt with the exact parsed action snapshot', async () => {
|
|
const snapshot = JSON.stringify({
|
|
type: 'unknown',
|
|
args: {},
|
|
rationale: 'This action is not supported.',
|
|
})
|
|
jest.spyOn(global, 'fetch').mockResolvedValue(new Response(JSON.stringify({
|
|
content: [{ type: 'text', text: snapshot }],
|
|
}), {
|
|
status: 200,
|
|
headers: { 'X-D3RO-Generation-Id': '22222222-2222-4222-8222-222222222222' },
|
|
}))
|
|
|
|
await expect(parseActionCommand('Do something', { accessToken: 'access-token' }))
|
|
.resolves.toMatchObject({
|
|
generationId: '22222222-2222-4222-8222-222222222222',
|
|
snapshot,
|
|
action: { type: 'unknown', executable: false },
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('confirmed action execution and local audit history', () => {
|
|
it('never executes an allowed action without an explicit confirmation flag', async () => {
|
|
const action = parseActionJson(JSON.stringify({
|
|
type: 'create_meeting',
|
|
args: { title: 'Review' },
|
|
rationale: 'Create a meeting.',
|
|
}))
|
|
await expect(executeConfirmedAction({
|
|
userId: USER_ID,
|
|
accessToken: 'access-token',
|
|
input: 'Create a review meeting',
|
|
action,
|
|
confirmed: false,
|
|
})).rejects.toMatchObject({ code: 'ACTION_BLOCKED' })
|
|
expect(mockCreateMeetingWorkspace).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('revalidates a caller-forged action object before any side effect', async () => {
|
|
const forged = {
|
|
type: 'create_meeting',
|
|
args: { title: 'Review', hidden: 'unreviewed' },
|
|
rationale: 'Create a meeting.',
|
|
executable: true,
|
|
} as unknown as ReturnType<typeof parseActionJson>
|
|
await expect(executeConfirmedAction({
|
|
userId: USER_ID,
|
|
accessToken: 'access-token',
|
|
input: 'Create a review meeting',
|
|
action: forged,
|
|
confirmed: true,
|
|
})).rejects.toMatchObject({ code: 'INVALID_REQUEST' })
|
|
expect(mockCreateMeetingWorkspace).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('creates a real meeting and records the confirmed result per user', async () => {
|
|
mockCreateMeetingWorkspace.mockResolvedValue({
|
|
id: MEETING_ID,
|
|
title: 'Review',
|
|
})
|
|
const action = parseActionJson(JSON.stringify({
|
|
type: 'create_meeting',
|
|
args: { title: 'Review' },
|
|
rationale: 'Create a meeting workspace.',
|
|
}))
|
|
const outcome = await executeConfirmedAction({
|
|
userId: USER_ID,
|
|
accessToken: 'access-token',
|
|
input: 'Create a review meeting',
|
|
action,
|
|
confirmed: true,
|
|
nowMs: 1_777_000_000_000,
|
|
})
|
|
|
|
expect(mockCreateMeetingWorkspace).toHaveBeenCalledWith(USER_ID, 'Review')
|
|
expect(outcome).toMatchObject({
|
|
historyRecorded: true,
|
|
result: { kind: 'meeting-created', meetingId: MEETING_ID, title: 'Review' },
|
|
record: { status: 'succeeded', type: 'create_meeting' },
|
|
})
|
|
await expect(listActionHistory(USER_ID)).resolves.toEqual([
|
|
expect.objectContaining({ status: 'succeeded', type: 'create_meeting' }),
|
|
])
|
|
})
|
|
|
|
it('executes knowledge search through the server contract and records only the count', async () => {
|
|
mockSearchKnowledge.mockResolvedValue([{
|
|
id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
|
|
documentId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
|
|
chunkIndex: 0,
|
|
content: 'Private knowledge result',
|
|
similarity: 0.91,
|
|
}])
|
|
const action = parseActionJson(JSON.stringify({
|
|
type: 'search_knowledge',
|
|
args: { query: 'release policy' },
|
|
rationale: 'Search is read only.',
|
|
}))
|
|
const outcome = await executeConfirmedAction({
|
|
userId: USER_ID,
|
|
accessToken: 'access-token',
|
|
input: 'Search release policy',
|
|
action,
|
|
confirmed: true,
|
|
nowMs: 1_777_000_000_001,
|
|
})
|
|
expect(outcome.result).toMatchObject({ kind: 'knowledge-searched' })
|
|
expect(outcome.record.result).toEqual({
|
|
kind: 'knowledge-searched',
|
|
query: 'release policy',
|
|
result_count: 1,
|
|
})
|
|
expect(JSON.stringify(outcome.record.result)).not.toContain('Private knowledge result')
|
|
})
|
|
|
|
it('persists a failed memo execution without reporting success', async () => {
|
|
mockGetMeetingDetail.mockRejectedValue(new Error('server refused meeting lookup'))
|
|
const action = parseActionJson(JSON.stringify({
|
|
type: 'create_memo',
|
|
args: { meeting_id: MEETING_ID, content: 'Decision recorded' },
|
|
rationale: 'Save the decision to this meeting.',
|
|
}))
|
|
|
|
await expect(executeConfirmedAction({
|
|
userId: USER_ID,
|
|
accessToken: 'access-token',
|
|
input: 'Save the decision',
|
|
action,
|
|
confirmed: true,
|
|
nowMs: 1_777_000_000_002,
|
|
})).rejects.toMatchObject({ code: 'SERVER_ERROR', historyRecorded: true })
|
|
await expect(listActionHistory(USER_ID)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
status: 'failed',
|
|
type: 'create_memo',
|
|
result: null,
|
|
errorCode: 'SERVER_ERROR',
|
|
}),
|
|
])
|
|
expect(mockCreateMeetingMemo).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('returns a completed side effect with an explicit history failure instead of inviting a duplicate retry', async () => {
|
|
mockCreateMeetingWorkspace.mockResolvedValue({ id: MEETING_ID, title: 'Review' })
|
|
jest.spyOn(AsyncStorage, 'setItem').mockRejectedValueOnce(new Error('disk unavailable'))
|
|
const action = parseActionJson(JSON.stringify({
|
|
type: 'create_meeting',
|
|
args: { title: 'Review' },
|
|
rationale: 'Create a meeting workspace.',
|
|
}))
|
|
await expect(executeConfirmedAction({
|
|
userId: USER_ID,
|
|
accessToken: 'access-token',
|
|
input: 'Create a review meeting',
|
|
action,
|
|
confirmed: true,
|
|
nowMs: 1_777_000_000_003,
|
|
})).resolves.toMatchObject({
|
|
historyRecorded: false,
|
|
result: { kind: 'meeting-created', meetingId: MEETING_ID },
|
|
})
|
|
expect(mockCreateMeetingWorkspace).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('creates a memo only after loading the RLS-visible meeting', async () => {
|
|
const meeting = { id: MEETING_ID, started_at: '2026-08-21T00:00:00.000Z' }
|
|
mockGetMeetingDetail.mockResolvedValue({ meeting })
|
|
mockCreateMeetingMemo.mockResolvedValue({
|
|
id: MEMO_ID,
|
|
meeting_id: MEETING_ID,
|
|
content: 'Decision recorded',
|
|
})
|
|
const action = parseActionJson(JSON.stringify({
|
|
type: 'create_memo',
|
|
args: { meeting_id: MEETING_ID, content: 'Decision recorded' },
|
|
rationale: 'Save the decision to this meeting.',
|
|
}))
|
|
await expect(executeConfirmedAction({
|
|
userId: USER_ID,
|
|
accessToken: 'access-token',
|
|
input: 'Save the decision',
|
|
action,
|
|
confirmed: true,
|
|
nowMs: 1_777_000_000_004,
|
|
})).resolves.toMatchObject({
|
|
result: { kind: 'memo-created', memoId: MEMO_ID },
|
|
})
|
|
expect(mockGetMeetingDetail).toHaveBeenCalledWith(USER_ID, MEETING_ID)
|
|
expect(mockCreateMeetingMemo).toHaveBeenCalledWith(
|
|
USER_ID,
|
|
meeting,
|
|
'Decision recorded',
|
|
1_777_000_000_004,
|
|
)
|
|
})
|
|
})
|