diff --git a/README.md b/README.md index 9d2579a..a9867da 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,13 @@ Windows Task сам использует Windows PowerShell, поэтому от ## Acceptance-тест встречи -`tests/calendar-event.js` описывает первый продуктовый путь: demo-команда создаёт предложение, -действие ждёт подтверждения, после подтверждения завершается и создаёт ровно одно событие. Повтор с -тем же `requestKey` не создаёт дубль. +`tests/calendar-event.js` описывает первый продуктовый путь: сообщение проходит через Channel +Gateway и Conversation Service, который создаёт действие и возвращает виджет подтверждения. После +подтверждения действие завершается и создаёт ровно одно событие. Повтор с тем же `requestKey` не +создаёт ни второе действие, ни второе событие. + +Команда подтверждения также проходит через Channel Gateway. Поэтому тест воспроизводит границу, +которой будут пользоваться Widget SDK и адаптеры каналов, а не обращается к Action Service напрямую. Сначала подними полный локальный стенд в репозитории `deploy`. Затем заполни в локальном `.env` учётные данные только тестового пользователя и ключ проверочного API: @@ -47,10 +51,11 @@ Runner сам получает короткоживущий JWT у локаль Если задан `DOCKER_NETWORK`, k6 подключается к существующей Compose-сети и принимает внутренние имена `channel-gateway`, `action-service` и `calendar-mcp`. Сам `test-lab` эту сеть не создаёт. -Путь начинается с публичной границы `Channel Gateway`, затем проходит через Agent Runtime, Action -Service, Temporal и Calendar MCP. Один JWT передаётся по этому пути; каждый защищённый сервис -самостоятельно проверяет подпись, issuer, срок и свой audience. Идентификаторы пользователя и tenant -не передаются в JSON запроса: сервисы получают их из проверенных claims `sub` и `tenant_id`. +Путь начинается с публичной границы `Channel Gateway`, затем проходит через Conversation Service, +Agent Runtime, Action Service, Temporal и Calendar MCP. Один JWT передаётся по этому пути; каждый +защищённый сервис самостоятельно проверяет подпись, issuer, срок и свой audience. Идентификаторы +пользователя и tenant не передаются в JSON запроса: сервисы получают их из проверенных claims `sub` +и `tenant_id`. ## Границы тестов diff --git a/SERVICE.md b/SERVICE.md index dc3160f..711c89a 100644 --- a/SERVICE.md +++ b/SERVICE.md @@ -6,7 +6,7 @@ | Бизнес-код | отсутствует | | Основной инструмент | k6; contract-first сценарии | | Chaos | отдельный ручной запуск, выключен по умолчанию | -| Первый acceptance-путь | Channel Gateway → Agent Runtime → Action Service → Temporal → Calendar MCP | +| Первый acceptance-путь | Channel Gateway → Conversation Service → Agent Runtime → Widget decision через Channel Gateway → Action Service → Temporal → Calendar MCP | | Локальный запуск | `task test:e2e` против уже поднятого окружения | | Граница ответственности | Не поднимает сервисы и не содержит их component-тесты | diff --git a/scripts/check.ps1 b/scripts/check.ps1 index 0eea349..40887aa 100644 --- a/scripts/check.ps1 +++ b/scripts/check.ps1 @@ -10,13 +10,17 @@ foreach ($required in @("TARGET_URL", "thresholds", "http_req_failed", "http_req if ($script -notmatch $required) { throw "k6 test does not contain $required." } } $calendarScript = Get-Content tests/calendar-event.js -Raw -foreach ($required in @("AWAITING_APPROVAL", "SUCCEEDED", "payloadHash", "requestKey", "result?.eventId", "http_req_failed", "CALENDAR_TEST_API_KEY", "X-Test-Key", "CHANNEL_URL", "/api/v1/messages", "requiresApproval", "Authorization")) { +foreach ($required in @("AWAITING_APPROVAL", "SUCCEEDED", "payloadHash", "requestKey", "result?.eventId", "http_req_failed", "CALENDAR_TEST_API_KEY", "X-Test-Key", "CHANNEL_URL", "/api/v1/conversations/messages", "action_confirmation", "Authorization")) { if ($calendarScript -notmatch [regex]::Escape($required)) { throw "Calendar acceptance test does not contain $required." } } +if ($calendarScript -notmatch [regex]::Escape('${channelUrl}/api/v1/actions/${card.actionId}/decisions')) { + throw "Widget decision must pass through Channel Gateway." +} foreach ($oldName in @("utterance", "tenant_id", "actor_id", "available_connectors", "requires_approval")) { if ($calendarScript -match [regex]::Escape($oldName)) { throw "Calendar acceptance test still contains old field $oldName." } } if ($calendarScript -match "/api/v1/proposals") { throw "Acceptance-test must start through Channel Gateway." } +if ($calendarScript -match 'function createAction') { throw "Conversation Service must create the action." } $runner = Get-Content scripts/run-calendar.ps1 -Raw foreach ($required in @("ACTION_TOKEN", "KEYCLOAK_URL", "OIDC_REALM", "OIDC_CLIENT_ID", "TEST_USERNAME", "TEST_PASSWORD", "protocol/openid-connect/token", "--add-host", "host.docker.internal:host-gateway", "DOCKER_NETWORK", "--network")) { diff --git a/tests/calendar-event.js b/tests/calendar-event.js index b518c25..892c1ee 100644 --- a/tests/calendar-event.js +++ b/tests/calendar-event.js @@ -29,17 +29,22 @@ export const options = { export default function () { const requestKey = `calendar-test-${Date.now()}-${__VU}-${__ITER}`; - const proposal = createProposal(); - const action = createAction(proposal, requestKey); + const card = createConversation(requestKey); + const action = getAction(card.actionId); check(action, { 'action waits for approval': (item) => item.status === 'AWAITING_APPROVAL', + 'action keeps the exact event data': (item) => + item.payload.title === eventData.title && + item.payload.startAt === eventData.startAt && + item.payload.endAt === eventData.endAt && + item.payload.timeZone === eventData.timeZone, }); check(findEvents(requestKey), { 'calendar is empty before approval': (items) => items.length === 0, }); - confirmAction(action); + confirmAction(card); const done = waitForDone(action.id); const events = findEvents(requestKey); @@ -58,20 +63,20 @@ export default function () { 'calendar event id matches action result': (items) => items[0]?.eventId === done.result?.eventId, }); - const repeated = createAction(proposal, requestKey); + const repeated = createConversation(requestKey); check(repeated, { - 'repeated request returns the same action': (item) => item.id === action.id, + 'repeated message returns the same action': (item) => item.actionId === action.id, }); check(findEvents(requestKey), { 'repeated request does not create a second event': (items) => items.length === 1, }); } -function createProposal() { +function createConversation(requestKey) { const response = http.post( - `${channelUrl}/api/v1/messages`, + `${channelUrl}/api/v1/conversations/messages`, JSON.stringify({ - requestKey: `proposal-${Date.now()}-${__VU}-${__ITER}`, + requestKey, text: `Создай встречу "${eventData.title}" с ${eventData.startAt} до ${eventData.endAt}`, context: { locale: 'ru-RU', @@ -80,43 +85,35 @@ function createProposal() { }), authHeaders(), ); - expectStatus(response, 200, 'channel-gateway did not create a proposal'); + expectStatus(response, 200, 'conversation route did not return a confirmation'); const body = response.json(); - if (!body.proposal || body.clarification) { - fail('channel-gateway returned no complete proposal'); + if (body.reply?.type !== 'confirmation') { + fail('conversation route returned no confirmation reply'); } - if (!body.proposal.requiresApproval) { - fail('channel-gateway proposal does not require approval'); + const card = body.reply.card; + if (card?.widget !== 'action_confirmation') { + fail('conversation route returned an unsupported widget'); } - check(body.proposal.payload, { - 'proposal keeps the exact event data': (payload) => - payload.title === eventData.title && - payload.startAt === eventData.startAt && - payload.endAt === eventData.endAt && - payload.timeZone === eventData.timeZone, + check(card, { + 'confirmation points to an action': (item) => Boolean(item.actionId), + 'confirmation protects the payload': (item) => /^[a-f0-9]{64}$/.test(item.payloadHash), + 'confirmation offers confirm and cancel': (item) => + item.actions?.some((action) => action.id === 'confirm') && + item.actions?.some((action) => action.id === 'cancel'), }); - return body.proposal; + return card; } -function createAction(proposal, requestKey) { - const response = http.post( - `${actionUrl}/api/v1/actions`, - JSON.stringify({ - kind: proposal.kind, - connector: proposal.connector, - payload: proposal.payload, - requestKey, - }), - authHeaders(), - ); - expectStatus(response, 201, 'action-service did not create an action'); +function getAction(actionId) { + const response = http.get(`${actionUrl}/api/v1/actions/${actionId}`, authHeaders()); + expectStatus(response, 200, 'action-service did not return the action'); return response.json(); } -function confirmAction(action) { +function confirmAction(card) { const response = http.post( - `${actionUrl}/api/v1/actions/${action.id}/decisions`, - JSON.stringify({ decision: 'CONFIRM', payloadHash: action.payloadHash }), + `${channelUrl}/api/v1/actions/${card.actionId}/decisions`, + JSON.stringify({ decision: 'CONFIRM', payloadHash: card.payloadHash }), authHeaders(), ); expectStatus(response, 202, 'action-service did not accept confirmation'); @@ -124,9 +121,7 @@ function confirmAction(action) { function waitForDone(actionId) { for (let attempt = 0; attempt < 20; attempt += 1) { - const response = http.get(`${actionUrl}/api/v1/actions/${actionId}`, authHeaders()); - expectStatus(response, 200, 'action-service did not return the action'); - const action = response.json(); + const action = getAction(actionId); if (action.status === 'SUCCEEDED' || action.status === 'FAILED') { return action; }