d3ro-voice/apps/mobile-rn/__tests__/action-service.test.ts
2026-08-29 18:33:45 +09:00

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,
)
})
})