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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@

## Назначение

Репозиторий хранит системные, нагрузочные и безопасные resilience-тесты Portable Agent.
Репозиторий хранит acceptance-, системные, нагрузочные и безопасные resilience-тесты Portable Agent.

## Границы

- не добавляй бизнес-код;
- не придумывай продуктовые ожидания: acceptance-сценарий должен ссылаться на решение из `platform`;
- цель теста приходит через переменную, настоящий production запрещён;
- chaos выключен по умолчанию и требует отдельную метку namespace;
- тест обязан иметь понятный порог успеха;
Expand All @@ -18,4 +19,3 @@
pwsh ./scripts/check.ps1
pwsh ./scripts/run-load.ps1
```

19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Test Lab

Отдельная лаборатория системных тестов Portable Agent. Она не знает бизнес-правил и проверяет
Отдельная лаборатория acceptance- и системных тестов Portable Agent. Она не придумывает
бизнес-правила: продуктовые ожидания берутся из документации `platform`. Лаборатория также проверяет
общие свойства платформы: доступность, задержку, обработку ошибок и восстановление.

## Быстрый старт
Expand All @@ -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 = "<local-test-token>"
pwsh ./scripts/run-calendar.ps1
```

Скрипт принимает только локальные HTTP-адреса. Проверочный API `fake-calendar` доступен только в
тестовом режиме.
2 changes: 1 addition & 1 deletion SERVICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

| Поле | Значение |
|---|---|
| Ответственность | системные, load и resilience-тесты |
| Ответственность | acceptance-, системные, load и resilience-тесты |
| Бизнес-код | отсутствует |
| Основной инструмент | k6 |
| Chaos | отдельный ручной запуск, выключен по умолчанию |
Expand Down
6 changes: 5 additions & 1 deletion scripts/check.ps1
Original file line number Diff line number Diff line change
@@ -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 не найден." }
Expand All @@ -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 прошли."

40 changes: 40 additions & 0 deletions scripts/run-calendar.ps1
Original file line number Diff line number Diff line change
@@ -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 "Сценарий создания встречи не прошёл." }
172 changes: 172 additions & 0 deletions tests/calendar-event.js
Original file line number Diff line number Diff line change
@@ -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(/\/$/, '');
}