Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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`.

## Границы тестов

Expand Down
2 changes: 1 addition & 1 deletion SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-тесты |

6 changes: 5 additions & 1 deletion scripts/check.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down
71 changes: 33 additions & 38 deletions tests/calendar-event.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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',
Expand All @@ -80,53 +85,43 @@ 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');
}

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;
}
Expand Down
Loading