From 3fc882d606aae26657205a4bced5dfc3a05a4696 Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Sun, 6 Sep 2026 00:11:20 +0300 Subject: [PATCH] test: define calendar event acceptance flow --- AGENTS.md | 4 +- README.md | 19 ++++- SERVICE.md | 2 +- scripts/check.ps1 | 6 +- scripts/run-calendar.ps1 | 40 +++++++++ tests/calendar-event.js | 172 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 scripts/run-calendar.ps1 create mode 100644 tests/calendar-event.js diff --git a/AGENTS.md b/AGENTS.md index efc58fe..3fe8e36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,11 +2,12 @@ ## Назначение -Репозиторий хранит системные, нагрузочные и безопасные resilience-тесты Portable Agent. +Репозиторий хранит acceptance-, системные, нагрузочные и безопасные resilience-тесты Portable Agent. ## Границы - не добавляй бизнес-код; +- не придумывай продуктовые ожидания: acceptance-сценарий должен ссылаться на решение из `platform`; - цель теста приходит через переменную, настоящий production запрещён; - chaos выключен по умолчанию и требует отдельную метку namespace; - тест обязан иметь понятный порог успеха; @@ -18,4 +19,3 @@ pwsh ./scripts/check.ps1 pwsh ./scripts/run-load.ps1 ``` - diff --git a/README.md b/README.md index 2bb97d6..ba78da9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Test Lab -Отдельная лаборатория системных тестов Portable Agent. Она не знает бизнес-правил и проверяет +Отдельная лаборатория acceptance- и системных тестов Portable Agent. Она не придумывает +бизнес-правила: продуктовые ожидания берутся из документации `platform`. Лаборатория также проверяет общие свойства платформы: доступность, задержку, обработку ошибок и восстановление. ## Быстрый старт @@ -14,3 +15,19 @@ pwsh ./scripts/run-load.ps1 По умолчанию Compose поднимает только локальный fake-service. Для внешней среды явно передай `TARGET_URL`; production URL скрипты отклоняют. Пороги k6 хранятся рядом со сценарием. +## Acceptance-тест встречи + +`tests/calendar-event.js` описывает первый продуктовый путь: demo-команда создаёт предложение, +действие ждёт подтверждения, после подтверждения завершается и создаёт ровно одно событие. Повтор с +тем же `requestKey` не создаёт дубль. + +До появления Temporal worker и `fake-calendar` этот тест намеренно красный. После запуска полного +локального стенда получи JWT тестового пользователя и выполни: + +```powershell +$env:ACTION_TOKEN = "" +pwsh ./scripts/run-calendar.ps1 +``` + +Скрипт принимает только локальные HTTP-адреса. Проверочный API `fake-calendar` доступен только в +тестовом режиме. diff --git a/SERVICE.md b/SERVICE.md index 308f2f1..a30e471 100644 --- a/SERVICE.md +++ b/SERVICE.md @@ -2,7 +2,7 @@ | Поле | Значение | |---|---| -| Ответственность | системные, load и resilience-тесты | +| Ответственность | acceptance-, системные, load и resilience-тесты | | Бизнес-код | отсутствует | | Основной инструмент | k6 | | Chaos | отдельный ручной запуск, выключен по умолчанию | diff --git a/scripts/check.ps1 b/scripts/check.ps1 index 5d43dd2..2a9b8d0 100644 --- a/scripts/check.ps1 +++ b/scripts/check.ps1 @@ -1,5 +1,5 @@ $ErrorActionPreference = "Stop" -foreach ($file in @(".env.example", "compose.yaml", "tests/smoke.js", "chaos/pod-delay.yaml", "README.md", "AGENTS.md", "SERVICE.md")) { +foreach ($file in @(".env.example", "compose.yaml", "tests/smoke.js", "tests/calendar-event.js", "scripts/run-calendar.ps1", "chaos/pod-delay.yaml", "README.md", "AGENTS.md", "SERVICE.md")) { if (-not (Test-Path $file)) { throw "Нет обязательного файла: $file" } } if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "Docker не найден." } @@ -9,5 +9,9 @@ $script = Get-Content tests/smoke.js -Raw foreach ($required in @("TARGET_URL", "thresholds", "http_req_failed", "http_req_duration")) { if ($script -notmatch $required) { throw "В k6-тесте нет $required." } } +$calendarScript = Get-Content tests/calendar-event.js -Raw +foreach ($required in @("AWAITING_APPROVAL", "SUCCEEDED", "payloadHash", "requestKey", "result?.eventId", "http_req_failed")) { + if ($calendarScript -notmatch [regex]::Escape($required)) { throw "В calendar acceptance-тесте нет $required." } +} Write-Host "Быстрые проверки test-lab прошли." diff --git a/scripts/run-calendar.ps1 b/scripts/run-calendar.ps1 new file mode 100644 index 0000000..86474b5 --- /dev/null +++ b/scripts/run-calendar.ps1 @@ -0,0 +1,40 @@ +param( + [string]$AgentUrl = "http://host.docker.internal:18080", + [string]$ActionUrl = "http://host.docker.internal:18081", + [string]$CalendarTestUrl = "http://host.docker.internal:18082" +) + +$ErrorActionPreference = "Stop" + +foreach ($url in @($AgentUrl, $ActionUrl, $CalendarTestUrl)) { + $uri = [Uri]$url + if ($uri.Scheme -ne "http" -or $uri.Host -notin @("localhost", "127.0.0.1", "host.docker.internal")) { + throw "Calendar acceptance-тест разрешён только для локальных HTTP-адресов." + } +} +if (-not $env:ACTION_TOKEN) { throw "Укажи ACTION_TOKEN с JWT тестового пользователя." } +$parts = $env:ACTION_TOKEN.Split('.') +if ($parts.Count -ne 3) { throw "ACTION_TOKEN не похож на JWT." } +$payload = $parts[1].Replace('-', '+').Replace('_', '/') +while ($payload.Length % 4) { $payload += '=' } +try { + $claims = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) | ConvertFrom-Json +} +catch { + throw "Не удалось прочитать claims из ACTION_TOKEN." +} +if (-not $claims.sub -or -not $claims.tenant_id) { + throw "В ACTION_TOKEN нужны claims sub и tenant_id." +} + +& docker run --rm ` + --volume "${PWD}/tests:/tests:ro" ` + --env "AGENT_URL=$AgentUrl" ` + --env "ACTION_URL=$ActionUrl" ` + --env "CALENDAR_TEST_URL=$CalendarTestUrl" ` + --env "ACTION_TOKEN=$env:ACTION_TOKEN" ` + --env "TEST_TENANT_ID=$($claims.tenant_id)" ` + --env "TEST_ACTOR_ID=$($claims.sub)" ` + "grafana/k6:2.2.0" run /tests/calendar-event.js + +if ($LASTEXITCODE -ne 0) { throw "Сценарий создания встречи не прошёл." } diff --git a/tests/calendar-event.js b/tests/calendar-event.js new file mode 100644 index 0000000..9903f68 --- /dev/null +++ b/tests/calendar-event.js @@ -0,0 +1,172 @@ +import http from 'k6/http'; +import { check, fail, sleep } from 'k6'; + +const agentUrl = requiredUrl('AGENT_URL'); +const actionUrl = requiredUrl('ACTION_URL'); +const calendarTestUrl = requiredUrl('CALENDAR_TEST_URL'); +const actionToken = required('ACTION_TOKEN'); +const tenantId = required('TEST_TENANT_ID'); +const actorId = required('TEST_ACTOR_ID'); +const eventData = { + title: 'Обсуждение проекта', + startAt: '2026-09-08T12:00:00+03:00', + endAt: '2026-09-08T12:30:00+03:00', + timeZone: 'Europe/Moscow', +}; + +export const options = { + scenarios: { + calendar_event: { + executor: 'shared-iterations', + vus: 1, + iterations: 1, + }, + }, + thresholds: { + checks: ['rate==1'], + http_req_failed: ['rate==0'], + }, +}; + +export default function () { + const requestKey = `calendar-test-${Date.now()}-${__VU}-${__ITER}`; + const proposal = createProposal(); + const action = createAction(proposal, requestKey); + + check(action, { + 'action waits for approval': (item) => item.status === 'AWAITING_APPROVAL', + }); + check(findEvents(requestKey), { + 'calendar is empty before approval': (items) => items.length === 0, + }); + + confirmAction(action); + const done = waitForDone(action.id); + const events = findEvents(requestKey); + + check(done, { + 'action succeeds after approval': (item) => item.status === 'SUCCEEDED', + 'action returns event id': (item) => Boolean(item.result?.eventId), + }); + check(events, { + 'calendar has one event': (items) => items.length === 1, + 'calendar keeps the exact title': (items) => items[0]?.title === eventData.title, + 'calendar keeps the exact start': (items) => items[0]?.startAt === eventData.startAt, + 'calendar keeps the exact end': (items) => items[0]?.endAt === eventData.endAt, + 'calendar keeps the exact time zone': (items) => items[0]?.timeZone === eventData.timeZone, + 'calendar event id matches action result': (items) => items[0]?.eventId === done.result?.eventId, + }); + + const repeated = createAction(proposal, requestKey); + check(repeated, { + 'repeated request returns the same action': (item) => item.id === action.id, + }); + check(findEvents(requestKey), { + 'repeated request does not create a second event': (items) => items.length === 1, + }); +} + +function createProposal() { + const response = http.post( + `${agentUrl}/api/v1/proposals`, + JSON.stringify({ + utterance: `Создай встречу "${eventData.title}" с ${eventData.startAt} до ${eventData.endAt}`, + context: { + tenant_id: tenantId, + actor_id: actorId, + timezone: eventData.timeZone, + available_connectors: ['fake-calendar'], + }, + }), + jsonHeaders(), + ); + expectStatus(response, 200, 'agent-runtime did not create a proposal'); + const body = response.json(); + if (!body.proposal || body.clarification) { + fail('agent-runtime returned no complete proposal'); + } + 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, + }); + return body.proposal; +} + +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'); + return response.json(); +} + +function confirmAction(action) { + const response = http.post( + `${actionUrl}/api/v1/actions/${action.id}/decisions`, + JSON.stringify({ decision: 'CONFIRM', payloadHash: action.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(); + if (action.status === 'SUCCEEDED' || action.status === 'FAILED') { + return action; + } + sleep(0.5); + } + fail('action did not finish in 10 seconds'); +} + +function findEvents(requestKey) { + const response = http.get( + `${calendarTestUrl}/test/events?requestKey=${encodeURIComponent(requestKey)}`, + ); + expectStatus(response, 200, 'fake-calendar test API is not available'); + return response.json().events; +} + +function jsonHeaders() { + return { headers: { 'Content-Type': 'application/json' } }; +} + +function authHeaders() { + return { + headers: { + Authorization: `Bearer ${actionToken}`, + 'Content-Type': 'application/json', + }, + }; +} + +function expectStatus(response, expected, message) { + if (response.status !== expected) { + fail(`${message}: expected ${expected}, got ${response.status}`); + } +} + +function required(name) { + const value = __ENV[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function requiredUrl(name) { + return required(name).replace(/\/$/, ''); +}