diff --git a/.github/workflows/am-user-platform.yml b/.github/workflows/am-user-platform.yml new file mode 100644 index 0000000..abeb6e8 --- /dev/null +++ b/.github/workflows/am-user-platform.yml @@ -0,0 +1,40 @@ +name: AM User Platform Publish + +on: + push: + branches: ["main", "develop", "test/**", "feature/**", "hotfix/**", "fix/**"] + paths: + - "am-user-platform/**" + - "libraries/**" + - "requirements.txt" + - "requirements-dev.txt" + - ".dockerignore" + - "docker/**" + pull_request: + branches: ["main", "develop", "test/**", "feature/**", "hotfix/**", "fix/**"] + paths: + - "am-user-platform/**" + - "libraries/**" + - "requirements.txt" + - "requirements-dev.txt" + - ".dockerignore" + - "docker/**" + workflow_dispatch: + +jobs: + publish: + name: Publish AM User Platform + permissions: + contents: read + packages: write + uses: AM-Portfolio/am-pipelines/.github/workflows/central-build-publish.yml@main + with: + language: "python" + working_directory: "am-user-platform" + image_name: "am-user-platform" + build_context: ".." + preprod_namespace: "am-apps-preprod" + prod_namespace: "am-apps-prod" + deploy_dev: true + deploy_prod: true + secrets: inherit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d93a711..e8a5d9e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: - "am-identity/**" - "am-subscription/**" - "am-notification/**" + - "am-user-platform/**" - "libraries/**" - "requirements*.txt" pull_request: @@ -15,6 +16,7 @@ on: - "am-identity/**" - "am-subscription/**" - "am-notification/**" + - "am-user-platform/**" - "libraries/**" - "requirements*.txt" @@ -37,10 +39,10 @@ jobs: pip install ./libraries/am-platform-common ./libraries/am-platform-security - name: Run Ruff Linter - run: ruff check am-identity am-subscription am-notification libraries + run: ruff check am-identity am-subscription am-notification am-user-platform libraries - name: Run Black Code Formatter Check - run: black --check am-identity am-subscription am-notification libraries + run: black --check am-identity am-subscription am-notification am-user-platform libraries - name: Run Unit Tests env: diff --git a/.github/workflows/deploy-am-user-platform.yml b/.github/workflows/deploy-am-user-platform.yml new file mode 100644 index 0000000..a5d6138 --- /dev/null +++ b/.github/workflows/deploy-am-user-platform.yml @@ -0,0 +1,38 @@ +name: Manual Deploy (AM User Platform) + +on: + workflow_dispatch: + inputs: + image_tag: + description: "Select Image Tag" + required: true + default: "latest" + type: choice + options: + - latest + - custom + custom_tag: + description: "If 'custom' selected, enter tag here" + required: false + type: string + environment: + description: "Target Environment" + required: true + default: "preprod" + type: choice + options: + - preprod + - prod + +jobs: + deploy: + name: Deploy AM User Platform to ${{ github.event.inputs.environment }} + uses: AM-Portfolio/am-pipelines/.github/workflows/central-deploy.yml@main + with: + service_name: "am-user-platform" + working_directory: "am-user-platform" + language: "python" + environment: ${{ github.event.inputs.environment }} + image_tag: ${{ github.event.inputs.image_tag == 'custom' && github.event.inputs.custom_tag || github.event.inputs.image_tag }} + namespace: ${{ github.event.inputs.environment == 'prod' && 'am-apps-prod' || 'am-apps-preprod' }} + secrets: inherit diff --git a/am-subscription/.am.yaml b/am-subscription/.am.yaml index 2857501..c0edcf2 100644 --- a/am-subscription/.am.yaml +++ b/am-subscription/.am.yaml @@ -4,7 +4,8 @@ image: ghcr.io/am-portfolio/am-subscription runtime: python language: python dockerfile: Dockerfile -context: . +# Dockerfile COPYs libraries/ + am-subscription/ — build from am-platform root +context: .. github: repo: AM-Portfolio/am-platform deploy_workflow: central-build-publish.yml diff --git a/am-subscription/am_subscription/api/internal_router.py b/am-subscription/am_subscription/api/internal_router.py index a1885d3..b96e419 100644 --- a/am-subscription/am_subscription/api/internal_router.py +++ b/am-subscription/am_subscription/api/internal_router.py @@ -27,6 +27,7 @@ INTERNAL_CLIENTS = { "am-gateway-client", + "am-fin-agent-service", "am-portfolio-service", "am-market-service", "am-market-data-service", diff --git a/am-subscription/am_subscription/schemas/subscription.py b/am-subscription/am_subscription/schemas/subscription.py index ccf1a66..047dbdd 100644 --- a/am-subscription/am_subscription/schemas/subscription.py +++ b/am-subscription/am_subscription/schemas/subscription.py @@ -19,6 +19,7 @@ class PlanLimitsDTO(BaseDTO): portfolios: int = 0 ai_portfolio_summaries: int = 0 api_calls: int = 0 + ai_chat_tokens: int = 0 class PlanDTO(BaseDTO): diff --git a/am-subscription/am_subscription/services/entitlement_service.py b/am-subscription/am_subscription/services/entitlement_service.py index 0f7cb63..f14f3c4 100644 --- a/am-subscription/am_subscription/services/entitlement_service.py +++ b/am-subscription/am_subscription/services/entitlement_service.py @@ -31,6 +31,7 @@ "portfolio.create": "portfolios", "ai.summary": "ai_portfolio_summaries", "api.call": "api_calls", + "ai.chat": "ai_chat_tokens", } diff --git a/am-subscription/helm/values.yaml b/am-subscription/helm/values.yaml index 55d0adf..f5d1e92 100644 --- a/am-subscription/helm/values.yaml +++ b/am-subscription/helm/values.yaml @@ -28,183 +28,195 @@ configMap: mountPath: /app/automation/helm data: lago-plans.json: | - { - "currency": "INR", - "billable_metrics": [ - { - "code": "document_parses", - "name": "AI Document Parses", - "description": "Number of AI document parse operations per billing period", - "aggregation_type": "count_agg" - }, - { - "code": "portfolios", - "name": "Portfolios", - "description": "Active portfolio count (max during period)", - "aggregation_type": "max_agg", - "field_name": "active_count" - }, - { - "code": "ai_portfolio_summaries", - "name": "AI Portfolio Summaries", - "description": "Basic AI portfolio summary generations per period", - "aggregation_type": "count_agg" - }, - { - "code": "api_calls", - "name": "API Calls", - "description": "Gateway and service API calls for metering", - "aggregation_type": "count_agg" - } - ], - "plans": [ - { - "code": "am_free", - "name": "Free", - "interval": "monthly", - "description": "Perfect for getting started with basic tracking.", - "amount_inr": 0, - "features": [ - "1 Portfolio tracking", - "End-of-day market data", - "Basic security search & charting", - "Standard trade journal", - "Up to 5 AI Document Parses/mo", - "Basic AI Portfolio Summary (1/mo)" - ], - "limits": { - "document_parses": 5, - "portfolios": 1, - "ai_portfolio_summaries": 1, - "api_calls": 1000 + { + "currency": "INR", + "billable_metrics": [ + { + "code": "document_parses", + "name": "AI Document Parses", + "description": "Number of AI document parse operations per billing period", + "aggregation_type": "count_agg" }, - "entitlements": { - "live_market_data": false, - "realtime_indices": false, - "tradingview_charts": false, - "basket_trading": false - } - }, - { - "code": "am_pro", - "name": "Pro", - "interval": "monthly", - "description": "Advanced tools for active investors and traders.", - "amount_inr": 999, - "features": [ - "Up to 5 Portfolios", - "Real-time indices tracking", - "Interactive TradingView charts", - "Interactive sector heatmaps", - "Calendar-based trade analytics", - "Win/Loss ratio tracking", - "Up to 50 AI Document Parses/mo", - "Auto Trade Categorization", - "AI Portfolio Insights & Health Check", - "AI Trade Performance Analytics" - ], - "limits": { - "document_parses": 50, - "portfolios": 5, - "ai_portfolio_summaries": 20, - "api_calls": 50000 + { + "code": "portfolios", + "name": "Portfolios", + "description": "Active portfolio count (max during period)", + "aggregation_type": "max_agg", + "field_name": "active_count" }, - "entitlements": { - "live_market_data": true, - "realtime_indices": true, - "tradingview_charts": true, - "basket_trading": false - } - }, - { - "code": "am_pro_annual", - "name": "Pro (Annual)", - "interval": "yearly", - "description": "Pro plan billed annually (₹799/mo effective).", - "amount_inr": 9588, - "features": [ - "Up to 5 Portfolios", - "Real-time indices tracking", - "Interactive TradingView charts", - "Up to 50 AI Document Parses/mo", - "AI Portfolio Insights & Health Check" - ], - "limits": { - "document_parses": 50, - "portfolios": 5, - "ai_portfolio_summaries": 20, - "api_calls": 50000 + { + "code": "ai_portfolio_summaries", + "name": "AI Portfolio Summaries", + "description": "Basic AI portfolio summary generations per period", + "aggregation_type": "count_agg" }, - "entitlements": { - "live_market_data": true, - "realtime_indices": true, - "tradingview_charts": true, - "basket_trading": false - } - }, - { - "code": "am_premium", - "name": "Premium", - "interval": "monthly", - "description": "The ultimate suite for professionals and active traders.", - "amount_inr": 1999, - "features": [ - "Basket creation & management", - "One-click basket execution", - "Advanced trade management", - "Up to 20 Portfolios & Analytics", - "ETF Explorer & key metrics", - "Up to 200 AI Document Parses/mo", - "Custom AI Trading Bots", - "Predictive Market Analytics", - "AI Portfolio Rebalancing Advice", - "AI Market Sentiment Analysis", - "AI Trade Setup Coach" - ], - "limits": { - "document_parses": 200, - "portfolios": 20, - "ai_portfolio_summaries": 100, - "api_calls": 250000 + { + "code": "api_calls", + "name": "API Calls", + "description": "Gateway and service API calls for metering", + "aggregation_type": "count_agg" }, - "entitlements": { - "live_market_data": true, - "realtime_indices": true, - "tradingview_charts": true, - "basket_trading": true, - "custom_ai_bots": true, - "predictive_analytics": true + { + "code": "ai_chat_tokens", + "name": "AI Chat Tokens", + "description": "LLM tokens consumed in AI chat per billing period", + "aggregation_type": "sum_agg", + "field_name": "tokens" } - }, - { - "code": "am_premium_annual", - "name": "Premium (Annual)", - "interval": "yearly", - "description": "Premium plan billed annually (₹1599/mo effective).", - "amount_inr": 19188, - "features": [ - "Up to 20 Portfolios & Analytics", - "Up to 200 AI Document Parses/mo", - "Custom AI Trading Bots", - "Predictive Market Analytics" - ], - "limits": { - "document_parses": 200, - "portfolios": 20, - "ai_portfolio_summaries": 100, - "api_calls": 250000 + ], + "plans": [ + { + "code": "am_free", + "name": "Free", + "interval": "monthly", + "description": "Perfect for getting started with basic tracking.", + "amount_inr": 0, + "features": [ + "1 Portfolio tracking", + "End-of-day market data", + "Basic security search & charting", + "Standard trade journal", + "Up to 5 AI Document Parses/mo", + "Basic AI Portfolio Summary (1/mo)" + ], + "limits": { + "document_parses": 5, + "portfolios": 1, + "ai_portfolio_summaries": 1, + "api_calls": 1000, + "ai_chat_tokens": 100000 + }, + "entitlements": { + "live_market_data": false, + "realtime_indices": false, + "tradingview_charts": false, + "basket_trading": false + } + }, + { + "code": "am_pro", + "name": "Pro", + "interval": "monthly", + "description": "Advanced tools for active investors and traders.", + "amount_inr": 999, + "features": [ + "Up to 5 Portfolios", + "Real-time indices tracking", + "Interactive TradingView charts", + "Interactive sector heatmaps", + "Calendar-based trade analytics", + "Win/Loss ratio tracking", + "Up to 50 AI Document Parses/mo", + "Auto Trade Categorization", + "AI Portfolio Insights & Health Check", + "AI Trade Performance Analytics" + ], + "limits": { + "document_parses": 50, + "portfolios": 5, + "ai_portfolio_summaries": 20, + "api_calls": 50000, + "ai_chat_tokens": 1000000 + }, + "entitlements": { + "live_market_data": true, + "realtime_indices": true, + "tradingview_charts": true, + "basket_trading": false + } + }, + { + "code": "am_pro_annual", + "name": "Pro (Annual)", + "interval": "yearly", + "description": "Pro plan billed annually (\u20b9799/mo effective).", + "amount_inr": 9588, + "features": [ + "Up to 5 Portfolios", + "Real-time indices tracking", + "Interactive TradingView charts", + "Up to 50 AI Document Parses/mo", + "AI Portfolio Insights & Health Check" + ], + "limits": { + "document_parses": 50, + "portfolios": 5, + "ai_portfolio_summaries": 20, + "api_calls": 50000, + "ai_chat_tokens": 1000000 + }, + "entitlements": { + "live_market_data": true, + "realtime_indices": true, + "tradingview_charts": true, + "basket_trading": false + } + }, + { + "code": "am_premium", + "name": "Premium", + "interval": "monthly", + "description": "The ultimate suite for professionals and active traders.", + "amount_inr": 1999, + "features": [ + "Basket creation & management", + "One-click basket execution", + "Advanced trade management", + "Up to 20 Portfolios & Analytics", + "ETF Explorer & key metrics", + "Up to 200 AI Document Parses/mo", + "Custom AI Trading Bots", + "Predictive Market Analytics", + "AI Portfolio Rebalancing Advice", + "AI Market Sentiment Analysis", + "AI Trade Setup Coach" + ], + "limits": { + "document_parses": 200, + "portfolios": 20, + "ai_portfolio_summaries": 100, + "api_calls": 250000, + "ai_chat_tokens": 1000000 + }, + "entitlements": { + "live_market_data": true, + "realtime_indices": true, + "tradingview_charts": true, + "basket_trading": true, + "custom_ai_bots": true, + "predictive_analytics": true + } }, - "entitlements": { - "live_market_data": true, - "realtime_indices": true, - "tradingview_charts": true, - "basket_trading": true, - "custom_ai_bots": true, - "predictive_analytics": true + { + "code": "am_premium_annual", + "name": "Premium (Annual)", + "interval": "yearly", + "description": "Premium plan billed annually (\u20b91599/mo effective).", + "amount_inr": 19188, + "features": [ + "Up to 20 Portfolios & Analytics", + "Up to 200 AI Document Parses/mo", + "Custom AI Trading Bots", + "Predictive Market Analytics" + ], + "limits": { + "document_parses": 200, + "portfolios": 20, + "ai_portfolio_summaries": 100, + "api_calls": 250000, + "ai_chat_tokens": 1000000 + }, + "entitlements": { + "live_market_data": true, + "realtime_indices": true, + "tradingview_charts": true, + "basket_trading": true, + "custom_ai_bots": true, + "predictive_analytics": true + } } - } - ] - } + ] + } # Empty stub — universal-chart batchSearch/scheduler templates require config to exist config: {} diff --git a/am-subscription/tests/test_ai_chat_tokens.py b/am-subscription/tests/test_ai_chat_tokens.py new file mode 100644 index 0000000..37e1f09 --- /dev/null +++ b/am-subscription/tests/test_ai_chat_tokens.py @@ -0,0 +1,50 @@ +"""Unit tests for ai_chat_tokens catalog wiring (Sprint B0).""" + +from am_subscription.schemas.subscription import PlanLimitsDTO +from am_subscription.services.entitlement_service import ACTION_TO_METRIC + + +def test_ai_chat_action_maps_to_token_metric(): + assert ACTION_TO_METRIC["ai.chat"] == "ai_chat_tokens" + + +def test_plan_limits_include_ai_chat_tokens_default(): + limits = PlanLimitsDTO() + assert limits.ai_chat_tokens == 0 + + +def test_plan_limits_accept_ai_chat_tokens_from_catalog(): + limits = PlanLimitsDTO( + document_parses=5, + portfolios=1, + ai_portfolio_summaries=1, + api_calls=1000, + ai_chat_tokens=100_000, + ) + assert limits.ai_chat_tokens == 100_000 + dumped = limits.model_dump() + assert dumped["ai_chat_tokens"] == 100_000 + + +def test_lago_plans_json_has_ai_chat_tokens_metric_and_limits(): + import json + from pathlib import Path + + # Prefer service-mounted catalog path shape; fall back to automation file. + candidates = [ + Path(__file__).resolve().parents[2] / "automation" / "helm" / "lago-plans.json", + Path(__file__).resolve().parents[3] / "automation" / "helm" / "lago-plans.json", + ] + path = next((p for p in candidates if p.is_file()), None) + assert path is not None, "lago-plans.json not found" + data = json.loads(path.read_text(encoding="utf-8")) + codes = {m["code"] for m in data["billable_metrics"]} + assert "ai_chat_tokens" in codes + metric = next(m for m in data["billable_metrics"] if m["code"] == "ai_chat_tokens") + assert metric["aggregation_type"] == "sum_agg" + assert metric.get("field_name") == "tokens" + + by_code = {p["code"]: p for p in data["plans"]} + assert by_code["am_free"]["limits"]["ai_chat_tokens"] == 100_000 + assert by_code["am_pro"]["limits"]["ai_chat_tokens"] == 1_000_000 + assert by_code["am_premium"]["limits"]["ai_chat_tokens"] == 1_000_000 diff --git a/am-user-platform/.am.yaml b/am-user-platform/.am.yaml new file mode 100644 index 0000000..278cb8c --- /dev/null +++ b/am-user-platform/.am.yaml @@ -0,0 +1,16 @@ +name: am-user-platform +release: am-user-platform +image: ghcr.io/am-portfolio/am-user-platform +runtime: python +language: python +dockerfile: Dockerfile +# Dockerfile COPYs libraries/ + am-user-platform/ — build from am-platform root +context: .. +github: + repo: AM-Portfolio/am-platform + deploy_workflow: deploy-am-user-platform.yml + service: am-user-platform + ref: main +deploy: + via: helm + rebuild: on-change diff --git a/am-user-platform/Dockerfile b/am-user-platform/Dockerfile new file mode 100644 index 0000000..a6209ff --- /dev/null +++ b/am-user-platform/Dockerfile @@ -0,0 +1,36 @@ +# AM User Platform — build context: am-platform root (build_context: ..) +# Public Python base. Avoids GHCR 403 on am-python-base. +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY libraries/am-platform-common/ libraries/am-platform-common/ +COPY libraries/am-platform-security/ libraries/am-platform-security/ +RUN pip install --no-cache-dir ./libraries/am-platform-common ./libraries/am-platform-security + +COPY am-user-platform/ am-user-platform/ + +ENV PYTHONPATH=/app/libraries/am-platform-common:/app/libraries/am-platform-security:/app/am-user-platform +ENV APP_ENV=production +ENV TZ=Asia/Kolkata +ENV APP_PORT=8080 + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=45s --retries=3 \ + CMD curl -f http://localhost:8080/health/live || exit 1 + +CMD ["uvicorn", "am_user_platform.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/am-user-platform/README.md b/am-user-platform/README.md new file mode 100644 index 0000000..5246f78 --- /dev/null +++ b/am-user-platform/README.md @@ -0,0 +1,115 @@ +# AM User Platform Service + +FastAPI modular monolith for user-facing app state. + +| | | +|--|--| +| **Local port** | **8115** | +| **Cluster port** | **8080** (container) | +| **v1 module** | `ai` — sessions, messages, feedback | + +## Run locally + +```powershell +cd am-platform +npm run user-platform:dev +``` + +Or: + +```powershell +cd am-platform/am-user-platform +pip install -e ../libraries/am-platform-common +pip install -e ../libraries/am-platform-security +pip install -e . +uvicorn am_user_platform.main:app --reload --port 8115 +``` + +Requires PostgreSQL database **`user_platform`** (schema **`ai`**). + +### Local Postgres setup + +1. Create database and user (once): + +```sql +CREATE USER am_user_platform_user WITH PASSWORD 'your_password'; +CREATE DATABASE user_platform OWNER am_user_platform_user; +GRANT ALL PRIVILEGES ON DATABASE user_platform TO am_user_platform_user; +``` + +2. Add to `am-platform/.secrets.env`: + +```env +AM_USER_PLATFORM_DB_NAME=user_platform +AM_USER_PLATFORM_DB_USER=am_user_platform_user +AM_USER_PLATFORM_DB_PASSWORD=your_password +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +``` + +3. Migration (or rely on startup `init_db()`): + +```powershell +cd am-platform/am-user-platform +python -m am_user_platform.modules.ai.migrations.versions.001_initial_ai_schema +``` + +## Env vars (main) + +| Env | Purpose | +|-----|---------| +| `APP_PORT` | Local default **8115**; cluster **8080** | +| `AM_USER_PLATFORM_DB_*` | Postgres name / user / password | +| `AM_USER_PLATFORM_POSTGRES_HOST` | Laptop override when `POSTGRES_HOST` is cluster DNS | +| `OIDC_JWKS_URL` / `OIDC_ISSUER` | JWT validation (Vault in cluster) | +| `SERVICE_TOKEN` | Optional; primary auth is Keycloak service JWT | + +## API summary + +| Prefix | Auth | Purpose | +|--------|------|---------| +| `GET /health`, `/health/live`, `/ready` | none | Probes | +| `/internal/ai/*` | Service Bearer | Agents append / context / purge | +| `/v1/user-platform/ai/*` | User JWT | Sessions + feedback | + +## Postman + +Import `postman/AM-User-Platform.postman_collection.json` + local environment. +See [`postman/README.md`](postman/README.md). + +## Docker build (from am-platform root) + +```powershell +cd am-platform +docker build -f am-user-platform/Dockerfile -t am-user-platform:local . +``` + +## Deploy with amctl + +Yes — same pattern as `am-subscription` / `am-notification`. From the service directory: + +```powershell +cd am-platform/am-user-platform + +# Dry-run first +am deploy --env dev --dry-run + +# Laptop Helm (needs kubeconfig + Vault paths populated) +am deploy --env dev --via helm + +# Default path: build/push + GitHub Actions Helm (after merge / workflow live) +am deploy --env dev +``` + +**Before first cluster deploy:** + +1. Create Vault path `apps/data/{env}/services/am-user-platform` with DB + OAuth keys +2. Provision Postgres DB `user_platform` (and keys under infra postgres secret) +3. Push/merge so `.github/workflows/am-user-platform.yml` exists on the remote repo + +`.am.yaml` sets `context: ..` so local Docker builds use the am-platform monorepo root (libraries + service). + +## Docs + +- [`docs/AI_USER_PLATFORM_FOLDER_STRUCTURE.md`](../../docs/AI_USER_PLATFORM_FOLDER_STRUCTURE.md) +- [`docs/AI_CHAT_PLATFORM_PLAN.md`](../../docs/AI_CHAT_PLATFORM_PLAN.md) diff --git a/am-user-platform/am_user_platform/__init__.py b/am-user-platform/am_user_platform/__init__.py new file mode 100644 index 0000000..acdec88 --- /dev/null +++ b/am-user-platform/am_user_platform/__init__.py @@ -0,0 +1 @@ +"""AM User Platform — modular monolith for user/employee app state.""" diff --git a/am-user-platform/am_user_platform/core/__init__.py b/am-user-platform/am_user_platform/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/am-user-platform/am_user_platform/core/config.py b/am-user-platform/am_user_platform/core/config.py new file mode 100644 index 0000000..0ee5466 --- /dev/null +++ b/am-user-platform/am_user_platform/core/config.py @@ -0,0 +1,66 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic import Field, computed_field +from pydantic_settings import BaseSettings, SettingsConfigDict + +PLATFORM_ROOT = Path(__file__).resolve().parents[3] + + +class UserPlatformSettings(BaseSettings): + app_name: str = Field(default="am-user-platform", alias="APP_NAME") + app_env: str = Field(default="dev", alias="APP_ENV") + app_port: int = Field(default=8115, alias="APP_PORT") + log_level: str = Field(default="INFO", alias="LOG_LEVEL") + + postgres_host: str = Field(default="localhost", alias="POSTGRES_HOST") + postgres_port: int = Field(default=5432, alias="POSTGRES_PORT") + user_platform_postgres_host: str | None = Field( + default=None, alias="AM_USER_PLATFORM_POSTGRES_HOST" + ) + user_platform_postgres_port: int | None = Field( + default=None, alias="AM_USER_PLATFORM_POSTGRES_PORT" + ) + db_name: str = Field(default="user_platform", alias="AM_USER_PLATFORM_DB_NAME") + db_user: str = Field( + default="am_user_platform_user", alias="AM_USER_PLATFORM_DB_USER" + ) + db_password: str = Field(default="", alias="AM_USER_PLATFORM_DB_PASSWORD") + postgres_ssl: bool = Field(default=False, alias="AM_USER_PLATFORM_POSTGRES_SSL") + + service_token: str = Field(default="", alias="SERVICE_TOKEN") + + model_config = SettingsConfigDict( + env_file=(str(PLATFORM_ROOT / ".env"), str(PLATFORM_ROOT / ".secrets.env")), + case_sensitive=False, + extra="ignore", + ) + + @property + def effective_postgres_host(self) -> str: + if self.user_platform_postgres_host: + return self.user_platform_postgres_host + return self.postgres_host + + @property + def effective_postgres_port(self) -> int: + if self.user_platform_postgres_port is not None: + return self.user_platform_postgres_port + return self.postgres_port + + @computed_field # type: ignore[prop-decorator] + @property + def database_url(self) -> str: + return ( + f"postgresql+asyncpg://{self.db_user}:{self.db_password}" + f"@{self.effective_postgres_host}:{self.effective_postgres_port}/{self.db_name}" + ) + + @property + def engine_connect_args(self) -> dict[str, bool]: + return {"ssl": self.postgres_ssl} + + +@lru_cache(maxsize=1) +def get_settings() -> UserPlatformSettings: + return UserPlatformSettings() diff --git a/am-user-platform/am_user_platform/core/database.py b/am-user-platform/am_user_platform/core/database.py new file mode 100644 index 0000000..10d0f8b --- /dev/null +++ b/am-user-platform/am_user_platform/core/database.py @@ -0,0 +1,92 @@ +from collections.abc import AsyncGenerator + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from am_user_platform.core.config import UserPlatformSettings, get_settings +from am_user_platform.core.log_utils import get_logger + +logger = get_logger("database") + + +class Base(DeclarativeBase): + pass + + +_engine = None +_session_factory: async_sessionmaker[AsyncSession] | None = None + + +def get_engine(settings: UserPlatformSettings | None = None): + global _engine, _session_factory + if _engine is None: + settings = settings or get_settings() + _engine = create_async_engine( + settings.database_url, + echo=False, + pool_pre_ping=True, + connect_args=settings.engine_connect_args, + ) + _session_factory = async_sessionmaker(_engine, expire_on_commit=False) + return _engine + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + get_engine() + assert _session_factory is not None + return _session_factory + + +async def init_db() -> None: + """Create schema ai and tables from registered SQLAlchemy models.""" + # Import models so they register on Base.metadata before create_all. + from am_user_platform.modules.ai import models as ai_models # noqa: F401 + + settings = get_settings() + engine = get_engine(settings) + tables = [t.name for t in Base.metadata.sorted_tables] + logger.info( + "Initializing database schema", + extra={ + "db_host": settings.effective_postgres_host, + "db_name": settings.db_name, + "schema": ai_models.AI_SCHEMA, + "tables": tables, + }, + ) + async with engine.begin() as conn: + await conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {ai_models.AI_SCHEMA}")) + await conn.run_sync(Base.metadata.create_all) + logger.info("Database schema ready", extra={"table_count": len(tables)}) + + +async def ping_db(settings: UserPlatformSettings | None = None) -> bool: + settings = settings or get_settings() + try: + engine = get_engine(settings) + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + return True + except Exception: + logger.exception("Database ping failed") + return False + + +async def get_db_session() -> AsyncGenerator[AsyncSession, None]: + factory = get_session_factory() + async with factory() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + + +async def close_db() -> None: + global _engine, _session_factory + if _engine is not None: + await _engine.dispose() + _engine = None + _session_factory = None diff --git a/am-user-platform/am_user_platform/core/log_utils.py b/am-user-platform/am_user_platform/core/log_utils.py new file mode 100644 index 0000000..b2f3477 --- /dev/null +++ b/am-user-platform/am_user_platform/core/log_utils.py @@ -0,0 +1,7 @@ +import logging + +SERVICE_LOGGER_PREFIX = "am_user_platform" + + +def get_logger(module: str) -> logging.Logger: + return logging.getLogger(f"{SERVICE_LOGGER_PREFIX}.{module}") diff --git a/am-user-platform/am_user_platform/deps.py b/am-user-platform/am_user_platform/deps.py new file mode 100644 index 0000000..04efaec --- /dev/null +++ b/am-user-platform/am_user_platform/deps.py @@ -0,0 +1,46 @@ +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from am_platform_security import AuthContext, require_service_account +from am_user_platform.core.config import UserPlatformSettings, get_settings +from am_user_platform.core.database import get_db_session +from am_user_platform.modules.ai.services.feedback_service import FeedbackService +from am_user_platform.modules.ai.services.message_service import MessageService +from am_user_platform.modules.ai.services.session_service import SessionService + +# Service-to-service auth for internal routers (Sprint C §4). +RequireServiceAccount = Depends(require_service_account()) + + +def get_session_service( + session: AsyncSession = Depends(get_db_session), +) -> SessionService: + return SessionService(session) + + +def get_message_service( + session: AsyncSession = Depends(get_db_session), +) -> MessageService: + return MessageService(session) + + +def get_feedback_service( + session: AsyncSession = Depends(get_db_session), +) -> FeedbackService: + return FeedbackService(session) + + +__all__ = [ + "AuthContext", + "FeedbackService", + "MessageService", + "RequireServiceAccount", + "SessionService", + "UserPlatformSettings", + "get_db_session", + "get_feedback_service", + "get_message_service", + "get_session_service", + "get_settings", + "require_service_account", +] diff --git a/am-user-platform/am_user_platform/main.py b/am-user-platform/am_user_platform/main.py new file mode 100644 index 0000000..ab73b09 --- /dev/null +++ b/am-user-platform/am_user_platform/main.py @@ -0,0 +1,129 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from sqlalchemy.exc import SQLAlchemyError + +from am_user_platform.modules.ai.api.internal_router import router as internal_ai_router +from am_user_platform.modules.ai.api.session_router import router as session_ai_router +from am_user_platform.core.config import get_settings +from am_user_platform.core.database import close_db, init_db, ping_db +from am_user_platform.core.log_utils import get_logger +from am_platform_common import ( + APIException, + InternalServerError, + LoggingMiddleware, + setup_logging, +) + +settings = get_settings() +setup_logging(env=settings.app_env, level=settings.log_level) +logger = get_logger("main") + + +@asynccontextmanager +async def lifespan(_: FastAPI): + logger.info( + "Starting am-user-platform", + extra={"port": settings.app_port, "env": settings.app_env}, + ) + try: + await init_db() + except Exception: + logger.exception( + "Database init failed — health will report degraded until Postgres is available" + ) + yield + await close_db() + logger.info("Shutting down am-user-platform") + + +app = FastAPI( + title="AM User Platform Service", + version="0.1.0", + description="User-facing platform state — AI chat memory (ai module), preferences, corp", + lifespan=lifespan, +) +app.add_middleware(LoggingMiddleware) + + +def _request_context(request: Request) -> dict[str, str]: + return { + "http_method": request.method, + "http_path": request.url.path, + } + + +@app.exception_handler(APIException) +async def api_exception_handler(request: Request, exc: APIException) -> JSONResponse: + if exc.status_code >= 500: + logger.error( + exc.message, + extra={ + **_request_context(request), + "error_code": exc.error_code, + "details": exc.details, + }, + ) + else: + logger.warning( + exc.message, + extra={ + **_request_context(request), + "error_code": exc.error_code, + "details": exc.details, + }, + ) + return JSONResponse(status_code=exc.status_code, content=exc.to_dict()) + + +@app.exception_handler(SQLAlchemyError) +async def sqlalchemy_exception_handler( + request: Request, exc: SQLAlchemyError +) -> JSONResponse: + logger.exception("Database error", extra=_request_context(request)) + body = InternalServerError( + message="Database operation failed", + error_code="DATABASE_ERROR", + details={"type": type(exc).__name__}, + ) + return JSONResponse(status_code=body.status_code, content=body.to_dict()) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + logger.exception( + "Unhandled exception", + extra={**_request_context(request), "exception_type": type(exc).__name__}, + ) + message = ( + str(exc) + if settings.app_env.lower() in ("dev", "local") + else "Internal server error" + ) + body = InternalServerError(message=message, error_code="INTERNAL_SERVER_ERROR") + return JSONResponse(status_code=body.status_code, content=body.to_dict()) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok", "service": settings.app_name} + + +@app.get("/health/live") +async def health_live() -> dict[str, str]: + return {"status": "ok", "service": settings.app_name} + + +@app.get("/ready") +async def ready() -> dict[str, object]: + db_ok = await ping_db() + return { + "status": "ok" if db_ok else "degraded", + "service": settings.app_name, + "database": db_ok, + } + + +app.include_router(session_ai_router) +app.include_router(internal_ai_router) diff --git a/am-user-platform/am_user_platform/modules/__init__.py b/am-user-platform/am_user_platform/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/am-user-platform/am_user_platform/modules/ai/__init__.py b/am-user-platform/am_user_platform/modules/ai/__init__.py new file mode 100644 index 0000000..045043c --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/__init__.py @@ -0,0 +1 @@ +"""AI chat memory module — sessions, messages, feedback (Sprint C §2–§4).""" diff --git a/am-user-platform/am_user_platform/modules/ai/api/__init__.py b/am-user-platform/am_user_platform/modules/ai/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/am-user-platform/am_user_platform/modules/ai/api/internal_router.py b/am-user-platform/am_user_platform/modules/ai/api/internal_router.py new file mode 100644 index 0000000..472a8c0 --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/api/internal_router.py @@ -0,0 +1,75 @@ +"""Internal AI APIs — service token (agents, gateway).""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, Query, status + +from am_platform_common import APIResponse +from am_platform_security import AuthContext, require_service_account + +from am_user_platform.deps import get_message_service, get_session_service +from am_user_platform.modules.ai.schemas.message import ( + AppendMessagesRequest, + ContextResponse, + MessageResponse, +) +from am_user_platform.modules.ai.services.message_service import MessageService +from am_user_platform.modules.ai.services.session_service import SessionService + +INTERNAL_CLIENTS = { + "am-gateway-client", + "am-fin-agent", + "am-fin-agent-service", + "am-tool-agent", + "am-qa-agent", + "am-support-agent", +} + +ServiceAuth = require_service_account(allowed_client_ids=INTERNAL_CLIENTS) + +router = APIRouter(prefix="/internal/ai", tags=["internal-ai"]) + + +@router.get( + "/sessions/{session_id}/context", + response_model=APIResponse[ContextResponse], +) +async def get_session_context( + session_id: uuid.UUID, + user_id: str = Query(..., min_length=1), + limit: int = Query(default=20, ge=1, le=100), + _: AuthContext = Depends(ServiceAuth), + service: MessageService = Depends(get_message_service), +): + data = await service.get_context(session_id, user_id, limit=limit) + return APIResponse(data=data) + + +@router.post( + "/sessions/{session_id}/messages", + response_model=APIResponse[list[MessageResponse]], + status_code=status.HTTP_201_CREATED, +) +async def append_session_messages( + session_id: uuid.UUID, + payload: AppendMessagesRequest, + _: AuthContext = Depends(ServiceAuth), + service: MessageService = Depends(get_message_service), +): + data = await service.append_messages(session_id, payload) + return APIResponse(data=data) + + +@router.delete( + "/users/{user_id}/data", + response_model=APIResponse[dict[str, int]], +) +async def purge_user_ai_data( + user_id: str, + _: AuthContext = Depends(ServiceAuth), + service: SessionService = Depends(get_session_service), +): + deleted = await service.purge_user_data(user_id) + return APIResponse(data={"sessions_deleted": deleted}) diff --git a/am-user-platform/am_user_platform/modules/ai/api/session_router.py b/am-user-platform/am_user_platform/modules/ai/api/session_router.py new file mode 100644 index 0000000..bf0eedc --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/api/session_router.py @@ -0,0 +1,115 @@ +"""User-facing AI APIs — JWT via gateway.""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, Query, status + +from am_platform_common import APIResponse +from am_platform_security import AuthContext, require_auth_context + +from am_user_platform.deps import ( + get_feedback_service, + get_message_service, + get_session_service, +) +from am_user_platform.modules.ai.schemas.feedback import FeedbackCreate, FeedbackResponse +from am_user_platform.modules.ai.schemas.session import ( + SessionCreate, + SessionDetailResponse, + SessionListResponse, + SessionResponse, + SessionUpdate, +) +from am_user_platform.modules.ai.services.feedback_service import FeedbackService +from am_user_platform.modules.ai.services.message_service import MessageService +from am_user_platform.modules.ai.services.session_service import SessionService + +UserAuth = require_auth_context() + +router = APIRouter(prefix="/v1/user-platform/ai", tags=["ai-sessions"]) + + +@router.get("/sessions", response_model=APIResponse[SessionListResponse]) +async def list_sessions( + product_id: str | None = Query(default=None), + agent_type: str | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + context: AuthContext = Depends(UserAuth), + service: SessionService = Depends(get_session_service), +): + data = await service.list_sessions( + context.subject, + product_id=product_id, + agent_type=agent_type, + limit=limit, + offset=offset, + ) + return APIResponse(data=data) + + +@router.post( + "/sessions", + response_model=APIResponse[SessionResponse], + status_code=status.HTTP_201_CREATED, +) +async def create_session( + payload: SessionCreate, + context: AuthContext = Depends(UserAuth), + service: SessionService = Depends(get_session_service), +): + data = await service.create_session(context.subject, payload) + return APIResponse(data=data) + + +@router.get("/sessions/{session_id}", response_model=APIResponse[SessionDetailResponse]) +async def get_session( + session_id: uuid.UUID, + context: AuthContext = Depends(UserAuth), + session_service: SessionService = Depends(get_session_service), + message_service: MessageService = Depends(get_message_service), +): + session = await session_service.get_session(session_id, context.subject) + messages = await message_service.get_messages(session_id, context.subject) + return APIResponse( + data=SessionDetailResponse(session=session, messages=messages) + ) + + +@router.patch("/sessions/{session_id}", response_model=APIResponse[SessionResponse]) +async def update_session( + session_id: uuid.UUID, + payload: SessionUpdate, + context: AuthContext = Depends(UserAuth), + service: SessionService = Depends(get_session_service), +): + data = await service.update_title(session_id, context.subject, payload.title) + return APIResponse(data=data) + + +@router.delete( + "/sessions/{session_id}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_session( + session_id: uuid.UUID, + context: AuthContext = Depends(UserAuth), + service: SessionService = Depends(get_session_service), +): + await service.soft_delete(session_id, context.subject) + + +@router.post( + "/feedback", + response_model=APIResponse[FeedbackResponse], + status_code=status.HTTP_201_CREATED, +) +async def create_feedback( + payload: FeedbackCreate, + context: AuthContext = Depends(UserAuth), + service: FeedbackService = Depends(get_feedback_service), +): + data = await service.create_feedback(context.subject, payload) + return APIResponse(data=data) diff --git a/am-user-platform/am_user_platform/modules/ai/migrations/versions/001_initial_ai_schema.py b/am-user-platform/am_user_platform/modules/ai/migrations/versions/001_initial_ai_schema.py new file mode 100644 index 0000000..a2fbbff --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/migrations/versions/001_initial_ai_schema.py @@ -0,0 +1,39 @@ +"""Initial ai schema migration — Sprint C §2. + +Run manually (same logic as startup init_db): + + python -m am_user_platform.modules.ai.migrations.versions.001_initial_ai_schema +""" + +from __future__ import annotations + +import asyncio + +from am_user_platform.core.database import init_db +from am_user_platform.core.log_utils import get_logger + +logger = get_logger("migration.001_initial_ai_schema") + +MIGRATION_ID = "001_initial_ai_schema" +DESCRIPTION = "Create schema ai and tables ai_sessions, ai_messages, ai_feedback" + + +async def upgrade() -> None: + logger.info("Running migration", extra={"id": MIGRATION_ID}) + await init_db() + logger.info("Migration complete", extra={"id": MIGRATION_ID}) + + +async def downgrade() -> None: + """No downgrade in v1 — drop schema manually if needed.""" + raise NotImplementedError( + "Downgrade not supported; use DROP SCHEMA ai CASCADE in non-prod only" + ) + + +def main() -> None: + asyncio.run(upgrade()) + + +if __name__ == "__main__": + main() diff --git a/am-user-platform/am_user_platform/modules/ai/models/__init__.py b/am-user-platform/am_user_platform/modules/ai/models/__init__.py new file mode 100644 index 0000000..c2f51d5 --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/models/__init__.py @@ -0,0 +1,17 @@ +"""SQLAlchemy models for the ai module (schema: ai).""" + +from am_user_platform.modules.ai.models.db import ( + AI_SCHEMA, + AiFeedback, + AiMessage, + AiSession, + MessageRole, +) + +__all__ = [ + "AI_SCHEMA", + "AiFeedback", + "AiMessage", + "AiSession", + "MessageRole", +] diff --git a/am-user-platform/am_user_platform/modules/ai/models/db.py b/am-user-platform/am_user_platform/modules/ai/models/db.py new file mode 100644 index 0000000..d6a559a --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/models/db.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import enum +import uuid +from datetime import datetime, timezone + +from sqlalchemy import ( + DateTime, + Enum, + ForeignKey, + Index, + Integer, + String, + Text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from am_user_platform.core.database import Base + +AI_SCHEMA = "ai" + + +class MessageRole(str, enum.Enum): + user = "user" + assistant = "assistant" + system = "system" + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class AiSession(Base): + __tablename__ = "ai_sessions" + __table_args__ = ( + Index( + "ix_ai_sessions_user_product_agent_updated", + "user_id", + "product_id", + "agent_type", + "updated_at", + ), + {"schema": AI_SCHEMA}, + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[str] = mapped_column(String(128), nullable=False) + product_id: Mapped[str] = mapped_column(String(64), nullable=False) + agent_type: Mapped[str] = mapped_column(String(64), nullable=False) + channel: Mapped[str] = mapped_column(String(32), nullable=False, default="user_app") + title: Mapped[str] = mapped_column(String(256), nullable=False, default="New chat") + org_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=_utcnow, + onupdate=_utcnow, + nullable=False, + ) + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + + messages: Mapped[list[AiMessage]] = relationship( + "AiMessage", + back_populates="session", + cascade="all, delete-orphan", + order_by="AiMessage.created_at", + ) + feedback: Mapped[list[AiFeedback]] = relationship( + "AiFeedback", + back_populates="session", + cascade="all, delete-orphan", + ) + + +class AiMessage(Base): + __tablename__ = "ai_messages" + __table_args__ = ( + Index("ix_ai_messages_session_created", "session_id", "created_at"), + {"schema": AI_SCHEMA}, + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey(f"{AI_SCHEMA}.ai_sessions.id", ondelete="CASCADE"), + nullable=False, + ) + role: Mapped[MessageRole] = mapped_column( + Enum( + MessageRole, + name="message_role", + schema=AI_SCHEMA, + create_constraint=True, + values_callable=lambda enum_cls: [member.value for member in enum_cls], + ), + nullable=False, + ) + content: Mapped[str] = mapped_column(Text, nullable=False, default="") + widget_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + widget_params: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + tools_used: Mapped[list | None] = mapped_column(JSONB, nullable=True) + tokens_used: Mapped[int | None] = mapped_column(Integer, nullable=True) + trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, nullable=False + ) + + session: Mapped[AiSession] = relationship("AiSession", back_populates="messages") + + +class AiFeedback(Base): + __tablename__ = "ai_feedback" + __table_args__ = ( + Index("ix_ai_feedback_user_created", "user_id", "created_at"), + Index("ix_ai_feedback_session_message", "session_id", "message_id"), + {"schema": AI_SCHEMA}, + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[str] = mapped_column(String(128), nullable=False) + session_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey(f"{AI_SCHEMA}.ai_sessions.id", ondelete="CASCADE"), + nullable=False, + ) + message_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey(f"{AI_SCHEMA}.ai_messages.id", ondelete="SET NULL"), + nullable=True, + ) + agent_type: Mapped[str] = mapped_column(String(64), nullable=False) + rating: Mapped[str] = mapped_column(String(16), nullable=False) + comment: Mapped[str | None] = mapped_column(Text, nullable=True) + trace_id: Mapped[str | None] = mapped_column(String(128), nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_utcnow, nullable=False + ) + + session: Mapped[AiSession] = relationship("AiSession", back_populates="feedback") + + +def ensure_ai_schema_sql() -> str: + return f"CREATE SCHEMA IF NOT EXISTS {AI_SCHEMA}" + + +def schema_create_statement() -> str: + """Documented DDL entry point (tables created via SQLAlchemy metadata).""" + return ensure_ai_schema_sql() diff --git a/am-user-platform/am_user_platform/modules/ai/schemas/feedback.py b/am-user-platform/am_user_platform/modules/ai/schemas/feedback.py new file mode 100644 index 0000000..b21ad1f --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/schemas/feedback.py @@ -0,0 +1,29 @@ +"""Pydantic schemas for AI feedback.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from am_platform_common import BaseDTO + + +class FeedbackCreate(BaseDTO): + session_id: UUID + message_id: UUID | None = None + agent_type: str + rating: str + comment: str | None = None + trace_id: str | None = None + + +class FeedbackResponse(BaseDTO): + id: UUID + user_id: str + session_id: UUID + message_id: UUID | None = None + agent_type: str + rating: str + comment: str | None = None + trace_id: str | None = None + created_at: datetime diff --git a/am-user-platform/am_user_platform/modules/ai/schemas/message.py b/am-user-platform/am_user_platform/modules/ai/schemas/message.py new file mode 100644 index 0000000..c47e568 --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/schemas/message.py @@ -0,0 +1,47 @@ +"""Pydantic schemas for AI messages.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from am_platform_common import BaseDTO + +from am_user_platform.modules.ai.models.db import MessageRole + + +class MessageAppendItem(BaseDTO): + role: MessageRole + content: str = "" + widget_id: str | None = None + widget_params: dict | None = None + tools_used: list[str] | None = None + tokens_used: int | None = None + trace_id: str | None = None + id: UUID | None = None + + +class AppendMessagesRequest(BaseDTO): + user_id: str + product_id: str + agent_type: str + channel: str = "user_app" + messages: list[MessageAppendItem] + + +class MessageResponse(BaseDTO): + id: UUID + session_id: UUID + role: MessageRole + content: str + widget_id: str | None = None + widget_params: dict | None = None + tools_used: list[str] | None = None + tokens_used: int | None = None + trace_id: str | None = None + created_at: datetime + + +class ContextResponse(BaseDTO): + session_id: UUID + messages: list[MessageResponse] diff --git a/am-user-platform/am_user_platform/modules/ai/schemas/session.py b/am-user-platform/am_user_platform/modules/ai/schemas/session.py new file mode 100644 index 0000000..dd7941b --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/schemas/session.py @@ -0,0 +1,46 @@ +"""Pydantic schemas for AI sessions.""" + +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from am_platform_common import BaseDTO + +from am_user_platform.modules.ai.schemas.message import MessageResponse + + +class SessionCreate(BaseDTO): + product_id: str + agent_type: str + channel: str = "user_app" + title: str | None = None + id: UUID | None = None + + +class SessionUpdate(BaseDTO): + title: str + + +class SessionResponse(BaseDTO): + id: UUID + user_id: str + product_id: str + agent_type: str + channel: str + title: str + org_id: UUID | None = None + created_at: datetime + updated_at: datetime + + +class SessionListResponse(BaseDTO): + items: list[SessionResponse] + total: int + limit: int + offset: int + + +class SessionDetailResponse(BaseDTO): + session: SessionResponse + messages: list[MessageResponse] diff --git a/am-user-platform/am_user_platform/modules/ai/services/feedback_service.py b/am-user-platform/am_user_platform/modules/ai/services/feedback_service.py new file mode 100644 index 0000000..99c5928 --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/services/feedback_service.py @@ -0,0 +1,55 @@ +"""Feedback persistence for the ai module.""" + +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from am_platform_common import NotFoundError + +from am_user_platform.core.log_utils import get_logger +from am_user_platform.modules.ai.models.db import AiFeedback, AiMessage +from am_user_platform.modules.ai.schemas.feedback import FeedbackCreate, FeedbackResponse +from am_user_platform.modules.ai.services.session_service import SessionService + +logger = get_logger("feedback_service") + + +class FeedbackService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._sessions = SessionService(session) + + async def create_feedback( + self, user_id: str, payload: FeedbackCreate + ) -> FeedbackResponse: + await self._sessions.get_session_row(payload.session_id, user_id) + if payload.message_id is not None: + message = await self._session.get(AiMessage, payload.message_id) + if message is None or message.session_id != payload.session_id: + raise NotFoundError( + "Message not found in session", + error_code="MESSAGE_NOT_FOUND", + details={"message_id": str(payload.message_id)}, + ) + + row = AiFeedback( + user_id=user_id, + session_id=payload.session_id, + message_id=payload.message_id, + agent_type=payload.agent_type, + rating=payload.rating, + comment=payload.comment, + trace_id=payload.trace_id, + ) + self._session.add(row) + await self._session.flush() + await self._session.refresh(row) + logger.info( + "feedback created", + extra={ + "user_id": user_id, + "session_id": str(payload.session_id), + "rating": payload.rating, + }, + ) + return FeedbackResponse.model_validate(row) diff --git a/am-user-platform/am_user_platform/modules/ai/services/helpers.py b/am-user-platform/am_user_platform/modules/ai/services/helpers.py new file mode 100644 index 0000000..fb990f3 --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/services/helpers.py @@ -0,0 +1,15 @@ +"""Shared helpers for the ai module.""" + +from __future__ import annotations + +TITLE_MAX_LEN = 50 +DEFAULT_TITLE = "New chat" + + +def title_from_first_message(content: str) -> str: + text = content.strip().replace("\n", " ") + if not text: + return DEFAULT_TITLE + if len(text) <= TITLE_MAX_LEN: + return text + return f"{text[: TITLE_MAX_LEN - 1].rstrip()}…" diff --git a/am-user-platform/am_user_platform/modules/ai/services/message_service.py b/am-user-platform/am_user_platform/modules/ai/services/message_service.py new file mode 100644 index 0000000..af911e9 --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/services/message_service.py @@ -0,0 +1,145 @@ +"""Message append and context reads for the ai module.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from am_user_platform.core.log_utils import get_logger +from am_user_platform.modules.ai.models.db import AiMessage, AiSession, MessageRole +from am_user_platform.modules.ai.schemas.message import ( + AppendMessagesRequest, + ContextResponse, + MessageAppendItem, + MessageResponse, +) +from am_user_platform.modules.ai.schemas.session import SessionCreate +from am_user_platform.modules.ai.services.helpers import ( + DEFAULT_TITLE, + title_from_first_message, +) +from am_user_platform.modules.ai.services.session_service import SessionService + +logger = get_logger("message_service") + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _to_message_response(row: AiMessage) -> MessageResponse: + return MessageResponse.model_validate(row) + + +class MessageService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + self._sessions = SessionService(session) + + async def append_messages( + self, + session_id: uuid.UUID, + payload: AppendMessagesRequest, + ) -> list[MessageResponse]: + ai_session = await self._resolve_session(session_id, payload) + created: list[AiMessage] = [] + + for item in payload.messages: + row = AiMessage( + id=item.id or uuid.uuid4(), + session_id=ai_session.id, + role=item.role, + content=item.content, + widget_id=item.widget_id, + widget_params=item.widget_params, + tools_used=item.tools_used, + tokens_used=item.tokens_used, + trace_id=item.trace_id, + ) + self._session.add(row) + created.append(row) + + ai_session.updated_at = _utcnow() + self._maybe_set_title(ai_session, payload.messages) + await self._session.flush() + for row in created: + await self._session.refresh(row) + + logger.info( + "messages appended", + extra={ + "session_id": str(session_id), + "count": len(created), + "user_id": payload.user_id, + }, + ) + return [_to_message_response(row) for row in created] + + async def get_messages( + self, session_id: uuid.UUID, user_id: str + ) -> list[MessageResponse]: + await self._sessions.get_session_row(session_id, user_id) + result = await self._session.execute( + select(AiMessage) + .where(AiMessage.session_id == session_id) + .order_by(AiMessage.created_at.asc()) + ) + return [_to_message_response(row) for row in result.scalars().all()] + + async def get_context( + self, session_id: uuid.UUID, user_id: str, *, limit: int = 20 + ) -> ContextResponse: + await self._sessions.get_session_row(session_id, user_id) + result = await self._session.execute( + select(AiMessage) + .where(AiMessage.session_id == session_id) + .order_by(AiMessage.created_at.desc()) + .limit(limit) + ) + rows = list(result.scalars().all()) + rows.reverse() + return ContextResponse( + session_id=session_id, + messages=[_to_message_response(row) for row in rows], + ) + + async def _resolve_session( + self, session_id: uuid.UUID, payload: AppendMessagesRequest + ) -> AiSession: + result = await self._session.execute( + select(AiSession).where( + AiSession.id == session_id, + AiSession.user_id == payload.user_id, + AiSession.deleted_at.is_(None), + ) + ) + row = result.scalar_one_or_none() + if row is not None: + return row + + create_payload = SessionCreate( + id=session_id, + product_id=payload.product_id, + agent_type=payload.agent_type, + channel=payload.channel, + ) + await self._sessions.create_session(payload.user_id, create_payload) + result = await self._session.execute( + select(AiSession).where(AiSession.id == session_id) + ) + created = result.scalar_one() + return created + + @staticmethod + def _maybe_set_title( + ai_session: AiSession, messages: list[MessageAppendItem] + ) -> None: + if ai_session.title not in (DEFAULT_TITLE, ""): + return + for item in messages: + if item.role == MessageRole.user and item.content.strip(): + ai_session.title = title_from_first_message(item.content) + return diff --git a/am-user-platform/am_user_platform/modules/ai/services/session_service.py b/am-user-platform/am_user_platform/modules/ai/services/session_service.py new file mode 100644 index 0000000..96562a7 --- /dev/null +++ b/am-user-platform/am_user_platform/modules/ai/services/session_service.py @@ -0,0 +1,155 @@ +"""Session CRUD for the ai module.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from am_platform_common import NotFoundError + +from am_user_platform.core.log_utils import get_logger +from am_user_platform.modules.ai.models.db import AiSession +from am_user_platform.modules.ai.schemas.session import ( + SessionCreate, + SessionListResponse, + SessionResponse, +) + +logger = get_logger("session_service") + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _to_session_response(row: AiSession) -> SessionResponse: + return SessionResponse.model_validate(row) + + +class SessionService: + def __init__(self, session: AsyncSession) -> None: + self._session = session + + async def create_session( + self, + user_id: str, + payload: SessionCreate, + ) -> SessionResponse: + row = AiSession( + id=payload.id or uuid.uuid4(), + user_id=user_id, + product_id=payload.product_id, + agent_type=payload.agent_type, + channel=payload.channel, + title=payload.title or "New chat", + ) + self._session.add(row) + await self._session.flush() + await self._session.refresh(row) + logger.info( + "session created", + extra={ + "session_id": str(row.id), + "user_id": user_id, + "agent_type": row.agent_type, + }, + ) + return _to_session_response(row) + + async def list_sessions( + self, + user_id: str, + *, + product_id: str | None = None, + agent_type: str | None = None, + limit: int = 50, + offset: int = 0, + ) -> SessionListResponse: + filters = [ + AiSession.user_id == user_id, + AiSession.deleted_at.is_(None), + ] + if product_id: + filters.append(AiSession.product_id == product_id) + if agent_type: + filters.append(AiSession.agent_type == agent_type) + + total_result = await self._session.execute( + select(func.count()).select_from(AiSession).where(*filters) + ) + total = int(total_result.scalar_one()) + + result = await self._session.execute( + select(AiSession) + .where(*filters) + .order_by(AiSession.updated_at.desc()) + .limit(limit) + .offset(offset) + ) + items = [_to_session_response(row) for row in result.scalars().all()] + return SessionListResponse( + items=items, total=total, limit=limit, offset=offset + ) + + async def get_session( + self, session_id: uuid.UUID, user_id: str + ) -> SessionResponse: + row = await self._get_owned_session(session_id, user_id) + return _to_session_response(row) + + async def get_session_row( + self, session_id: uuid.UUID, user_id: str + ) -> AiSession: + return await self._get_owned_session(session_id, user_id) + + async def update_title( + self, session_id: uuid.UUID, user_id: str, title: str + ) -> SessionResponse: + row = await self._get_owned_session(session_id, user_id) + row.title = title.strip() or row.title + row.updated_at = _utcnow() + await self._session.flush() + await self._session.refresh(row) + return _to_session_response(row) + + async def soft_delete(self, session_id: uuid.UUID, user_id: str) -> None: + row = await self._get_owned_session(session_id, user_id) + row.deleted_at = _utcnow() + row.updated_at = _utcnow() + await self._session.flush() + + async def purge_user_data(self, user_id: str) -> int: + result = await self._session.execute( + select(AiSession).where(AiSession.user_id == user_id) + ) + rows = list(result.scalars().all()) + for row in rows: + await self._session.delete(row) + await self._session.flush() + logger.info( + "purged user ai data", + extra={"user_id": user_id, "sessions_deleted": len(rows)}, + ) + return len(rows) + + async def _get_owned_session( + self, session_id: uuid.UUID, user_id: str + ) -> AiSession: + result = await self._session.execute( + select(AiSession).where( + AiSession.id == session_id, + AiSession.user_id == user_id, + AiSession.deleted_at.is_(None), + ) + ) + row = result.scalar_one_or_none() + if row is None: + raise NotFoundError( + "Session not found", + error_code="SESSION_NOT_FOUND", + details={"session_id": str(session_id)}, + ) + return row diff --git a/am-user-platform/am_user_platform/modules/corp/README.md b/am-user-platform/am_user_platform/modules/corp/README.md new file mode 100644 index 0000000..6387409 --- /dev/null +++ b/am-user-platform/am_user_platform/modules/corp/README.md @@ -0,0 +1,12 @@ +# Corp module + +**Status:** Not implemented in v1. + +Reserved for employee settings with `org_id` (am-corps / enterprise). + +| Planned later | Notes | +|---------------|-------| +| Schema | `corp` (Postgres) | +| Key column | `org_id` (required when this module ships) | + +Do **not** import this module from `modules/ai` or `modules/preferences`. diff --git a/am-user-platform/am_user_platform/modules/corp/__init__.py b/am-user-platform/am_user_platform/modules/corp/__init__.py new file mode 100644 index 0000000..24545a1 --- /dev/null +++ b/am-user-platform/am_user_platform/modules/corp/__init__.py @@ -0,0 +1 @@ +"""Corp module — employee / org settings (not implemented in v1).""" diff --git a/am-user-platform/am_user_platform/modules/preferences/README.md b/am-user-platform/am_user_platform/modules/preferences/README.md new file mode 100644 index 0000000..938ae1a --- /dev/null +++ b/am-user-platform/am_user_platform/modules/preferences/README.md @@ -0,0 +1,12 @@ +# Preferences module + +**Status:** Not implemented in v1. + +Reserved for user dashboard layout, UI flags, and similar preferences. + +| Planned later | Notes | +|---------------|-------| +| Schema | `prefs` (Postgres) | +| Ownership | User JWT `sub` only — no org | + +Do **not** import this module from `modules/ai` or `modules/corp`. diff --git a/am-user-platform/am_user_platform/modules/preferences/__init__.py b/am-user-platform/am_user_platform/modules/preferences/__init__.py new file mode 100644 index 0000000..f6d75ff --- /dev/null +++ b/am-user-platform/am_user_platform/modules/preferences/__init__.py @@ -0,0 +1 @@ +"""Preferences module — dashboard / UI prefs (not implemented in v1).""" diff --git a/am-user-platform/helm/values.dev.yaml b/am-user-platform/helm/values.dev.yaml new file mode 100644 index 0000000..2a258ba --- /dev/null +++ b/am-user-platform/helm/values.dev.yaml @@ -0,0 +1,46 @@ +# Dev Environment — am-user-platform +environment: dev +replicaCount: 1 + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + +hostAliases: + - ip: "203.174.22.129" + hostnames: + - "postgres.asrax.in" + +vault: + enabled: true + secretPaths: + postgres: + path: "apps/data/dev/infra/postgres" + user-platform: + path: "apps/data/dev/services/am-user-platform" + identity-oidc: + path: "apps/data/dev/services/am-identity" + service-oauth: + path: "apps/data/dev/services/am-user-platform" + +ingress: + enabled: true + className: "traefik" + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure + traefik.ingress.kubernetes.io/router.middlewares: >- + am-apps-dev-dev-global-cors@kubernetescrd + hosts: + - host: am-dev.asrax.in + paths: + - path: /v1/user-platform + pathType: Prefix + +env: + APP_ENV: dev + LOG_LEVEL: DEBUG diff --git a/am-user-platform/helm/values.preprod.yaml b/am-user-platform/helm/values.preprod.yaml new file mode 100644 index 0000000..6c26801 --- /dev/null +++ b/am-user-platform/helm/values.preprod.yaml @@ -0,0 +1,41 @@ +# Preprod Environment — am-user-platform +environment: preprod +replicaCount: 1 + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 1m + memory: 64Mi + +vault: + enabled: true + secretPaths: + postgres: + path: "apps/data/preprod/infra/postgres" + user-platform: + path: "apps/data/preprod/services/am-user-platform" + identity-oidc: + path: "apps/data/preprod/services/am-identity" + service-oauth: + path: "apps/data/preprod/services/am-user-platform" + +env: + APP_ENV: preprod + LOG_LEVEL: INFO + +ingress: + enabled: true + className: "traefik" + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure + traefik.ingress.kubernetes.io/router.middlewares: >- + am-apps-preprod-global-cors@kubernetescrd + hosts: + - host: am-preprod.asrax.in + paths: + - path: /v1/user-platform + pathType: Prefix diff --git a/am-user-platform/helm/values.prod.yaml b/am-user-platform/helm/values.prod.yaml new file mode 100644 index 0000000..4a1c622 --- /dev/null +++ b/am-user-platform/helm/values.prod.yaml @@ -0,0 +1,41 @@ +# Production Environment — am-user-platform +environment: prod +replicaCount: 1 + +resources: + limits: + cpu: 1000m + memory: 768Mi + requests: + cpu: 500m + memory: 512Mi + +vault: + enabled: true + secretPaths: + postgres: + path: "apps/data/prod/infra/postgres" + user-platform: + path: "secret/data/prod/services/am-user-platform" + identity-oidc: + path: "secret/data/prod/services/am-identity" + service-oauth: + path: "secret/data/prod/services/am-user-platform" + +env: + APP_ENV: production + LOG_LEVEL: INFO + +ingress: + enabled: true + className: "traefik" + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure + traefik.ingress.kubernetes.io/router.middlewares: >- + am-apps-prod-global-cors@kubernetescrd + hosts: + - host: am.asrax.in + paths: + - path: /v1/user-platform + pathType: Prefix diff --git a/am-user-platform/helm/values.yaml b/am-user-platform/helm/values.yaml new file mode 100644 index 0000000..70fbe01 --- /dev/null +++ b/am-user-platform/helm/values.yaml @@ -0,0 +1,93 @@ +# Base Helm values for am-user-platform +global: + image: + registry: ghcr.io/am-portfolio + pullPolicy: Always + imagePullSecrets: + - name: regcred + vault: + enabled: true + role: "am-backend-role" + authPath: "auth/kubernetes" + serviceAccountName: "am-backend-sa" + +image: + repository: am-user-platform + +replicaCount: 1 +language: python +port: 8080 +service: + port: 8080 +entrypoint: "exec uvicorn am_user_platform.main:app --host 0.0.0.0 --port 8080" + +security: + automountServiceAccountToken: true + +# Empty stub — universal-chart batchSearch/scheduler templates require config to exist +config: {} + +# Vault key→env mappings live here so base+env values alone are enough for CI. +# Env files only override paths. +vault: + secretPaths: + postgres: + mappings: + POSTGRES_HOST: "host" + POSTGRES_USER: "username" + POSTGRES_PASSWORD: "password" + AM_USER_PLATFORM_POSTGRES_HOST: "host" + AM_USER_PLATFORM_POSTGRES_PORT: "port" + AM_USER_PLATFORM_DB_NAME: "AM_USER_PLATFORM_DB_NAME" + AM_USER_PLATFORM_DB_USER: "AM_USER_PLATFORM_DB_USER" + AM_USER_PLATFORM_DB_PASSWORD: "AM_USER_PLATFORM_DB_PASSWORD" + user-platform: + mappings: + AM_USER_PLATFORM_CLIENT_ID: "AM_USER_PLATFORM_CLIENT_ID" + AM_USER_PLATFORM_CLIENT_SECRET: "AM_USER_PLATFORM_CLIENT_SECRET" + SERVICE_TOKEN: "SERVICE_TOKEN" + identity-oidc: + mappings: + OIDC_JWKS_URL: "OIDC_JWKS_URL" + OIDC_ISSUER: "OIDC_ISSUER" + service-oauth: + mappings: + OAUTH2_CLIENT_ID: "AM_USER_PLATFORM_CLIENT_ID" + OAUTH2_CLIENT_SECRET: "AM_USER_PLATFORM_CLIENT_SECRET" + +env: + APP_ENV: production + APP_NAME: am-user-platform + APP_PORT: "8080" + LOG_FORMAT: json + LOG_LEVEL: INFO + TZ: Asia/Kolkata + AM_USER_PLATFORM_POSTGRES_SSL: "false" + +probes: + port: 8080 + startup: + path: /health/live + failureThreshold: 30 + periodSeconds: 10 + liveness: + path: /health/live + readiness: + path: /ready + +resources: + limits: + cpu: 500m + memory: 512Mi + requests: + cpu: 100m + memory: 256Mi + +# Host list is environment-specific — see values.*.yaml +ingress: + enabled: true + className: "traefik" + annotations: + kubernetes.io/ingress.class: "traefik" + traefik.ingress.kubernetes.io/router.entrypoints: web,websecure + hosts: [] diff --git a/am-user-platform/helm/vault-mappings.yaml b/am-user-platform/helm/vault-mappings.yaml new file mode 100644 index 0000000..e42d052 --- /dev/null +++ b/am-user-platform/helm/vault-mappings.yaml @@ -0,0 +1,27 @@ +# DEPRECATED for CI completeness — mappings are also in values.yaml so base+env +# files alone work. Kept for workflows that still pass -f vault-mappings.yaml. +vault: + secretPaths: + postgres: + mappings: + POSTGRES_HOST: "host" + POSTGRES_USER: "username" + POSTGRES_PASSWORD: "password" + AM_USER_PLATFORM_POSTGRES_HOST: "host" + AM_USER_PLATFORM_POSTGRES_PORT: "port" + AM_USER_PLATFORM_DB_NAME: "AM_USER_PLATFORM_DB_NAME" + AM_USER_PLATFORM_DB_USER: "AM_USER_PLATFORM_DB_USER" + AM_USER_PLATFORM_DB_PASSWORD: "AM_USER_PLATFORM_DB_PASSWORD" + user-platform: + mappings: + AM_USER_PLATFORM_CLIENT_ID: "AM_USER_PLATFORM_CLIENT_ID" + AM_USER_PLATFORM_CLIENT_SECRET: "AM_USER_PLATFORM_CLIENT_SECRET" + SERVICE_TOKEN: "SERVICE_TOKEN" + identity-oidc: + mappings: + OIDC_JWKS_URL: "OIDC_JWKS_URL" + OIDC_ISSUER: "OIDC_ISSUER" + service-oauth: + mappings: + OAUTH2_CLIENT_ID: "AM_USER_PLATFORM_CLIENT_ID" + OAUTH2_CLIENT_SECRET: "AM_USER_PLATFORM_CLIENT_SECRET" diff --git a/am-user-platform/observability.yaml b/am-user-platform/observability.yaml new file mode 100644 index 0000000..6f30311 --- /dev/null +++ b/am-user-platform/observability.yaml @@ -0,0 +1,17 @@ +apiVersion: am.obs/v1 +kind: ServiceObservability +service: am-user-platform +tier: B +runtime: python +k8s_app_label: am-user-platform +metrics_application: am-user-platform +namespace: am-apps-preprod +owners: [platform-team] +signals: + bundle: tier-b-python-core + domain: [] + include: [] +dashboard: + technical: + rows: + collapse: [row_k8s] diff --git a/am-user-platform/package.json b/am-user-platform/package.json new file mode 100644 index 0000000..fbb8d47 --- /dev/null +++ b/am-user-platform/package.json @@ -0,0 +1,15 @@ +{ + "name": "@am-platform/user-platform", + "version": "1.0.0", + "private": true, + "description": "AM User Platform service (AI chat memory, preferences, corp)", + "scripts": { + "dev": "node ../automation/scripts/run-with-logs.js python ../automation/scripts/run_service.py user-platform dev", + "start": "npm run dev", + "test": "node ../automation/scripts/run-with-logs.js python ../automation/scripts/run_user_platform_tests.py", + "lint": "node ../automation/scripts/run-with-logs.js python -m ruff check am_user_platform && python -m black --check am_user_platform", + "lint:fix": "node ../automation/scripts/run-with-logs.js python -m ruff check am_user_platform --fix", + "format": "node ../automation/scripts/run-with-logs.js python -m black am_user_platform", + "compile": "node ../automation/scripts/run-with-logs.js python -m compileall am_user_platform" + } +} diff --git a/am-user-platform/postman/AM-User-Platform.browser.postman_environment.json b/am-user-platform/postman/AM-User-Platform.browser.postman_environment.json new file mode 100644 index 0000000..d7e3a4b --- /dev/null +++ b/am-user-platform/postman/AM-User-Platform.browser.postman_environment.json @@ -0,0 +1,126 @@ +{ + "id": "am-user-platform-browser-env", + "name": "AM User Platform \u2014 Prod Public (Browser)", + "values": [ + { + "enabled": true, + "key": "public_base_url", + "value": "https://am.asrax.in", + "type": "default" + }, + { + "enabled": true, + "key": "gateway_url", + "value": "https://am.asrax.in/ai", + "type": "default" + }, + { + "enabled": true, + "key": "identity_base_url", + "value": "https://am.asrax.in", + "type": "default" + }, + { + "enabled": true, + "key": "keycloak_url", + "value": "https://auth.asrax.in/auth", + "type": "default" + }, + { + "enabled": true, + "key": "keycloak_realm", + "value": "am-realm", + "type": "default" + }, + { + "enabled": true, + "key": "base_url", + "value": "https://am.asrax.in", + "type": "default" + }, + { + "enabled": true, + "key": "gateway_client_id", + "value": "am-gateway-client", + "type": "default" + }, + { + "enabled": true, + "key": "gateway_client_secret", + "value": "", + "type": "secret" + }, + { + "enabled": true, + "key": "fin_agent_client_id", + "value": "am-fin-agent-service", + "type": "default" + }, + { + "enabled": true, + "key": "fin_agent_client_secret", + "value": "", + "type": "secret" + }, + { + "enabled": true, + "key": "test_email", + "value": "test.user@example.com", + "type": "default" + }, + { + "enabled": true, + "key": "test_password", + "value": "", + "type": "secret" + }, + { + "enabled": true, + "key": "access_token", + "value": "", + "type": "secret" + }, + { + "enabled": true, + "key": "service_access_token", + "value": "", + "type": "secret" + }, + { + "enabled": true, + "key": "user_sub", + "value": "", + "type": "default" + }, + { + "enabled": true, + "key": "session_id", + "value": "", + "type": "default" + }, + { + "enabled": true, + "key": "message_id", + "value": "", + "type": "default" + }, + { + "enabled": true, + "key": "product_id", + "value": "am_app", + "type": "default" + }, + { + "enabled": true, + "key": "agent_type", + "value": "fin_portfolio", + "type": "default" + }, + { + "enabled": true, + "key": "env_note", + "value": "browser Postman \u2014 use public + gateway URLs", + "type": "default" + } + ] +} \ No newline at end of file diff --git a/am-user-platform/postman/AM-User-Platform.local.postman_environment.json b/am-user-platform/postman/AM-User-Platform.local.postman_environment.json new file mode 100644 index 0000000..5ae4532 --- /dev/null +++ b/am-user-platform/postman/AM-User-Platform.local.postman_environment.json @@ -0,0 +1,111 @@ +{ + "id": "am-user-platform-local-env", + "name": "AM User Platform — Local", + "values": [ + { + "key": "base_url", + "value": "http://localhost:8115", + "type": "default", + "enabled": true + }, + { + "key": "identity_base_url", + "value": "https://am.asrax.in", + "type": "default", + "enabled": true + }, + { + "key": "keycloak_url", + "value": "https://auth.munish.org/auth", + "type": "default", + "enabled": true + }, + { + "key": "keycloak_realm", + "value": "am-realm", + "type": "default", + "enabled": true + }, + { + "key": "gateway_client_id", + "value": "am-gateway-client", + "type": "default", + "enabled": true + }, + { + "key": "gateway_client_secret", + "value": "", + "type": "secret", + "enabled": true + }, + { + "key": "fin_agent_client_id", + "value": "am-fin-agent-service", + "type": "default", + "enabled": true + }, + { + "key": "fin_agent_client_secret", + "value": "", + "type": "secret", + "enabled": true + }, + { + "key": "test_email", + "value": "test.user@example.com", + "type": "default", + "enabled": true + }, + { + "key": "test_password", + "value": "TestPass123!", + "type": "secret", + "enabled": true + }, + { + "key": "access_token", + "value": "", + "type": "secret", + "enabled": true + }, + { + "key": "service_access_token", + "value": "", + "type": "secret", + "enabled": true + }, + { + "key": "user_sub", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "session_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "message_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "product_id", + "value": "am_app", + "type": "default", + "enabled": true + }, + { + "key": "agent_type", + "value": "fin_portfolio", + "type": "default", + "enabled": true + } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-09-02T00:00:00.000Z", + "_postman_exported_using": "Cursor" +} diff --git a/am-user-platform/postman/AM-User-Platform.postman_collection.json b/am-user-platform/postman/AM-User-Platform.postman_collection.json new file mode 100644 index 0000000..4980105 --- /dev/null +++ b/am-user-platform/postman/AM-User-Platform.postman_collection.json @@ -0,0 +1,636 @@ +{ + "info": { + "name": "AM User Platform Service", + "description": "AI chat memory APIs for **am-user-platform** + gateway proxies.\n\n## Browser Postman (no desktop install)\n1. Top-right: select **AM User Platform \u2014 Prod Public (Browser)**\n2. Run **00 Auth** \u2192 Password Login, then Client Credentials (gateway)\n3. Run **02 User Sessions (public)** and **03 Gateway Sessions**\n\n**Browser cannot call localhost.** Health + Internal need laptop port-forward (`base_url=http://127.0.0.1:8115`) \u2014 use env **Prod Port-Forward** only on desktop or leave them to agent smoke scripts.\n\n| Folder | Works in browser? |\n|--------|-------------------|\n| 00 Auth | yes |\n| 01 Health / Internal | only with port-forward |\n| 02 User Sessions (public) | yes |\n| 03 Gateway Sessions | yes (what Flutter will call) |\n| 04 Feedback | yes |\n", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get('session_id')) {", + " pm.environment.set('session_id', pm.variables.replaceIn('{{$guid}}'));", + "}" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code < 200 || pm.response.code >= 300) return;", + "try {", + " const body = pm.response.json();", + " if (body.data) {", + " const d = body.data;", + " if (d.id && !d.session) pm.environment.set('session_id', d.id);", + " if (d.session && d.session.id) pm.environment.set('session_id', d.session.id);", + " if (Array.isArray(d) && d.length && d[d.length-1].id) {", + " pm.environment.set('message_id', d[d.length-1].id);", + " }", + " if (d.messages && d.messages.length) {", + " const last = d.messages[d.messages.length-1];", + " if (last.id) pm.environment.set('message_id', last.id);", + " }", + " }", + "} catch (e) {}" + ] + } + } + ], + "variable": [ + { + "key": "public_base_url", + "value": "https://am.asrax.in" + }, + { + "key": "gateway_url", + "value": "https://am.asrax.in/ai" + }, + { + "key": "identity_base_url", + "value": "https://am.asrax.in" + }, + { + "key": "keycloak_url", + "value": "https://auth.asrax.in/auth" + }, + { + "key": "keycloak_realm", + "value": "am-realm" + }, + { + "key": "base_url", + "value": "http://127.0.0.1:8115" + }, + { + "key": "product_id", + "value": "am_app" + }, + { + "key": "agent_type", + "value": "fin_portfolio" + } + ], + "item": [ + { + "name": "00 Auth (run first)", + "item": [ + { + "name": "Password Login (am-identity)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{identity_base_url}}/identity/auth/login", + "description": "User JWT. Saves access_token + user_sub. Use this in browser.", + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"{{test_email}}\",\n \"password\": \"{{test_password}}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + } + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const b = pm.response.json();", + " if (b.access_token) {", + " pm.environment.set('access_token', b.access_token);", + " try {", + " const payload = JSON.parse(atob(b.access_token.split('.')[1]));", + " if (payload.sub) pm.environment.set('user_sub', payload.sub);", + " } catch (e) {}", + " }", + "}" + ] + } + } + ] + }, + { + "name": "Client Credentials (am-gateway-client)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "url": "{{keycloak_url}}/realms/{{keycloak_realm}}/protocol/openid-connect/token", + "description": "Service JWT for /internal/* (port-forward only).", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials" + }, + { + "key": "client_id", + "value": "{{gateway_client_id}}" + }, + { + "key": "client_secret", + "value": "{{gateway_client_secret}}" + } + ] + } + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const b = pm.response.json();", + " if (b.access_token) pm.environment.set('service_access_token', b.access_token);", + "}" + ] + } + } + ] + }, + { + "name": "Client Credentials (am-fin-agent-service)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/x-www-form-urlencoded" + } + ], + "url": "{{keycloak_url}}/realms/{{keycloak_realm}}/protocol/openid-connect/token", + "description": "Optional alternate service JWT. Needs fin_agent_client_secret in env.", + "body": { + "mode": "urlencoded", + "urlencoded": [ + { + "key": "grant_type", + "value": "client_credentials" + }, + { + "key": "client_id", + "value": "{{fin_agent_client_id}}" + }, + { + "key": "client_secret", + "value": "{{fin_agent_client_secret}}" + } + ] + } + }, + "response": [], + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const b = pm.response.json();", + " if (b.access_token) pm.environment.set('service_access_token', b.access_token);", + "}" + ] + } + } + ] + } + ] + }, + { + "name": "01 Health + Internal (port-forward only)", + "description": "Browser Postman cannot reach 127.0.0.1. Use desktop + kubectl port-forward, or skip.", + "item": [ + { + "name": "GET Health", + "request": { + "method": "GET", + "header": [], + "url": "{{base_url}}/health", + "description": "" + }, + "response": [] + }, + { + "name": "GET Ready", + "request": { + "method": "GET", + "header": [], + "url": "{{base_url}}/ready", + "description": "" + }, + "response": [] + }, + { + "name": "POST Append Messages (no auth \u2192 401)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{base_url}}/internal/ai/sessions/{{session_id}}/messages", + "description": "", + "body": { + "mode": "raw", + "raw": "{\n \"user_id\": \"{{user_sub}}\",\n \"product_id\": \"{{product_id}}\",\n \"agent_type\": \"{{agent_type}}\",\n \"messages\": [{\"role\": \"user\", \"content\": \"noauth\"}]\n}" + } + }, + "response": [] + }, + { + "name": "POST Append Messages", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{base_url}}/internal/ai/sessions/{{session_id}}/messages", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{service_access_token}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"user_id\": \"{{user_sub}}\",\n \"product_id\": \"{{product_id}}\",\n \"agent_type\": \"{{agent_type}}\",\n \"channel\": \"user_app\",\n \"messages\": [\n {\"role\": \"user\", \"content\": \"Show portfolio\"},\n {\"role\": \"assistant\", \"content\": \"Here is a summary\", \"tokens_used\": 100}\n ]\n}" + } + }, + "response": [] + }, + { + "name": "GET Session Context", + "request": { + "method": "GET", + "header": [], + "url": "{{base_url}}/internal/ai/sessions/{{session_id}}/context?user_id={{user_sub}}&limit=20", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{service_access_token}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "02 User Sessions (public \u2014 browser OK)", + "description": "https://am.asrax.in/v1/user-platform \u2014 works in browser Postman with user JWT.", + "item": [ + { + "name": "GET List Sessions", + "request": { + "method": "GET", + "header": [], + "url": "{{public_base_url}}/v1/user-platform/ai/sessions?product_id={{product_id}}&agent_type={{agent_type}}", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "POST Create Session", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{public_base_url}}/v1/user-platform/ai/sessions", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"product_id\": \"{{product_id}}\",\n \"agent_type\": \"{{agent_type}}\",\n \"title\": \"Postman browser session\"\n}" + } + }, + "response": [] + }, + { + "name": "GET Session Detail", + "request": { + "method": "GET", + "header": [], + "url": "{{public_base_url}}/v1/user-platform/ai/sessions/{{session_id}}", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "PATCH Rename Session", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{public_base_url}}/v1/user-platform/ai/sessions/{{session_id}}", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Renamed in Postman\"\n}" + } + }, + "response": [] + }, + { + "name": "DELETE Session", + "request": { + "method": "DELETE", + "header": [], + "url": "{{public_base_url}}/v1/user-platform/ai/sessions/{{session_id}}", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "03 Gateway Sessions (Flutter path \u2014 browser OK)", + "description": "https://am.asrax.in/ai/v1/ai/sessions* \u2014 same APIs the app will call.", + "item": [ + { + "name": "GET Gateway Health", + "request": { + "method": "GET", + "header": [], + "url": "{{gateway_url}}/v1/ai/health", + "description": "" + }, + "response": [] + }, + { + "name": "GET List Sessions via Gateway", + "request": { + "method": "GET", + "header": [], + "url": "{{gateway_url}}/v1/ai/sessions?product_id={{product_id}}&agent_type={{agent_type}}", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "POST Create Session via Gateway", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{gateway_url}}/v1/ai/sessions", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"product_id\": \"{{product_id}}\",\n \"agent_type\": \"{{agent_type}}\",\n \"title\": \"Gateway Postman session\"\n}" + } + }, + "response": [] + }, + { + "name": "GET Session via Gateway", + "request": { + "method": "GET", + "header": [], + "url": "{{gateway_url}}/v1/ai/sessions/{{session_id}}", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + } + }, + "response": [] + }, + { + "name": "PATCH Rename via Gateway", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{gateway_url}}/v1/ai/sessions/{{session_id}}", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"title\": \"Gateway renamed\"\n}" + } + }, + "response": [] + }, + { + "name": "DELETE Session via Gateway", + "request": { + "method": "DELETE", + "header": [], + "url": "{{gateway_url}}/v1/ai/sessions/{{session_id}}", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "04 Feedback (browser OK)", + "item": [ + { + "name": "POST Feedback (public user-platform)", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{public_base_url}}/v1/user-platform/ai/feedback", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"session_id\": \"{{session_id}}\",\n \"agent_type\": \"{{agent_type}}\",\n \"rating\": \"down\",\n \"comment\": \"postman\"\n}" + } + }, + "response": [] + }, + { + "name": "POST Feedback via Gateway", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": "{{gateway_url}}/v1/ai/feedback", + "description": "", + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{access_token}}", + "type": "string" + } + ] + }, + "body": { + "mode": "raw", + "raw": "{\n \"sessionId\": \"{{session_id}}\",\n \"rating\": \"thumbs_down\",\n \"comment\": \"gateway postman\"\n}" + } + }, + "response": [] + } + ] + } + ] +} \ No newline at end of file diff --git a/am-user-platform/postman/README.md b/am-user-platform/postman/README.md new file mode 100644 index 0000000..2a77ecb --- /dev/null +++ b/am-user-platform/postman/README.md @@ -0,0 +1,39 @@ +# AM User Platform — Postman (Browser) + +Use **Postman on the web** (no desktop install). + +## Select environment (required) + +Top-right dropdown must **not** say "No environment". + +| Environment | When | +|-------------|------| +| **AM User Platform — Prod Public (Browser)** | Browser Postman (recommended) | +| **AM User Platform — Prod Port-Forward** | Laptop + `kubectl port-forward` only | + +Your `invalid_client` error was from **No environment** (empty `client_secret`) or the wrong Keycloak client secret. + +## Browser run order + +1. Select **Prod Public (Browser)** +2. **00 Auth** → Password Login (am-identity) → expect 200 +3. **00 Auth** → Client Credentials (am-gateway-client) → expect 200 + *(optional)* Client Credentials (am-fin-agent-service) — also filled now +4. **02 User Sessions (public)** — list / create / get / rename / delete +5. **03 Gateway Sessions** — same via `https://am.asrax.in/ai` (Flutter path) +6. **04 Feedback** + +Skip **01 Health + Internal** in the browser — those need `127.0.0.1:8115`, which cloud Postman cannot reach. + +## Auth cheat sheet + +| Route | Token | +|-------|-------| +| `/v1/user-platform/ai/*` | User JWT from identity login | +| `/ai/v1/ai/sessions*` | Same user JWT via gateway | +| `/internal/ai/*` | Service JWT (port-forward only) | + +## Links + +- Collection: **AM User Platform Service** (updated) +- Browser env UID: see `postman/_postman_ids.json` diff --git a/am-user-platform/postman/_postman_ids.json b/am-user-platform/postman/_postman_ids.json new file mode 100644 index 0000000..16a7a37 --- /dev/null +++ b/am-user-platform/postman/_postman_ids.json @@ -0,0 +1,7 @@ +{ + "collection_uid": "56761657-a9fa5747-49c7-487e-8091-da3262f5a944", + "environment_uid_port_forward": "56761657-3f6f40c7-80f0-4a72-9b6d-344dfeb183c3", + "environment_uid_browser": "56761657-f2800861-e40f-40e2-9de3-be41a0123a0a", + "workspace": "c1ed9ba4-e485-4377-8374-7685abf4247d", + "test_email": "test.user@example.com" +} \ No newline at end of file diff --git a/am-user-platform/postman/_upload_payload.json b/am-user-platform/postman/_upload_payload.json new file mode 100644 index 0000000..1ee38d7 --- /dev/null +++ b/am-user-platform/postman/_upload_payload.json @@ -0,0 +1 @@ +{"info": {"name": "AM User Platform Service", "description": "API collection for **am-user-platform** (port **8115**) \u2014 AI chat memory (Sprint C \u00a74).\n\n## Quick start\n1. Import `AM-User-Platform.local.postman_environment.json`\n2. Set secrets: `gateway_client_secret`, `fin_agent_client_secret` (from `am-platform/.secrets.env`)\n3. Start service: `cd am-platform/am-user-platform && npm run dev`\n4. Ensure Postgres `user_platform` DB exists (see service README)\n5. **00 Health** \u2192 Health Check\n6. **99 Keycloak** \u2192 Client Credentials (am-gateway-client)\n7. **01 Internal** \u2192 Append Messages (creates session + saves `session_id`)\n8. **99 Keycloak** \u2192 Password Login \u2192 **02 User Sessions** \u2192 List / Get\n\n## Auth\n| Route prefix | Token |\n|--------------|-------|\n| `/internal/ai/*` | Service Bearer (`service_access_token`) |\n| `/v1/user-platform/ai/*` | User JWT (`access_token`) |\n\n## Folders\n| Folder | Purpose |\n|--------|----------|\n| 00 Health | Liveness + readiness |\n| 01 Internal AI | Agent/service \u2014 append, context, purge |\n| 02 User AI Sessions | User JWT \u2014 list, create, get, patch, delete |\n| 03 User Feedback | User JWT \u2014 thumbs up/down |\n| 99 Keycloak Helpers | Tokens for Postman testing |", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"}, "item": [{"name": "00 Health", "item": [{"name": "Health Check", "request": {"method": "GET", "header": [], "url": "{{base_url}}/health", "description": "Expect `{\"status\":\"ok\",\"service\":\"am-user-platform\"}`."}, "response": []}, {"name": "Ready Check", "request": {"method": "GET", "header": [], "url": "{{base_url}}/ready", "description": "Includes Postgres ping. `database: true` when DB is configured."}, "response": []}]}, {"name": "01 Internal AI (service token)", "description": "Run **99 Keycloak \u2192 Client Credentials (am-gateway-client)** first. Set `user_sub` from Password Login or manually.", "item": [{"name": "Append Messages (create session)", "event": [{"listen": "prerequest", "script": {"type": "text/javascript", "exec": ["if (!pm.environment.get('user_sub')) {", " console.warn('Set user_sub from Password Login or manually');", "}", "if (!pm.environment.get('session_id')) {", " pm.environment.set('session_id', pm.variables.replaceIn('{{$guid}}'));", "}"]}}], "request": {"auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{service_access_token}}", "type": "string"}]}, "method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\n \"user_id\": \"{{user_sub}}\",\n \"product_id\": \"{{product_id}}\",\n \"agent_type\": \"{{agent_type}}\",\n \"channel\": \"user_app\",\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": \"Show my portfolio summary\"\n },\n {\n \"role\": \"assistant\",\n \"content\": \"Here is your portfolio overview.\",\n \"tokens_used\": 3842,\n \"tools_used\": [\"get_portfolio_summary\"]\n }\n ]\n}"}, "url": "{{base_url}}/internal/ai/sessions/{{session_id}}/messages", "description": "Creates session if missing. Returns **201** with message array. Uses env `session_id` (auto-generated UUID if empty)."}, "response": []}, {"name": "Get Session Context", "request": {"auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{service_access_token}}", "type": "string"}]}, "method": "GET", "header": [], "url": {"raw": "{{base_url}}/internal/ai/sessions/{{session_id}}/context?user_id={{user_sub}}&limit=20", "host": ["{{base_url}}"], "path": ["internal", "ai", "sessions", "{{session_id}}", "context"], "query": [{"key": "user_id", "value": "{{user_sub}}"}, {"key": "limit", "value": "20"}]}, "description": "Last N turns for agent LLM context."}, "response": []}, {"name": "Purge User AI Data", "request": {"auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{service_access_token}}", "type": "string"}]}, "method": "DELETE", "header": [], "url": "{{base_url}}/internal/ai/users/{{user_sub}}/data", "description": "Account deletion hook \u2014 removes all sessions/messages/feedback for user."}, "response": []}, {"name": "Append Messages \u2014 no auth (expect 401)", "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\n \"user_id\": \"{{user_sub}}\",\n \"product_id\": \"am_app\",\n \"agent_type\": \"fin_portfolio\",\n \"messages\": [{ \"role\": \"user\", \"content\": \"test\" }]\n}"}, "url": "{{base_url}}/internal/ai/sessions/{{session_id}}/messages", "description": "Negative test \u2014 should return **401** without Bearer token."}, "response": []}]}, {"name": "02 User AI Sessions (user JWT)", "description": "Run **99 Keycloak \u2192 Password Login** first. User must own the session (`user_sub` matches session owner).", "item": [{"name": "List Sessions", "request": {"auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{access_token}}", "type": "string"}]}, "method": "GET", "header": [], "url": {"raw": "{{base_url}}/v1/user-platform/ai/sessions?product_id={{product_id}}&agent_type={{agent_type}}&limit=50", "host": ["{{base_url}}"], "path": ["v1", "user-platform", "ai", "sessions"], "query": [{"key": "product_id", "value": "{{product_id}}"}, {"key": "agent_type", "value": "{{agent_type}}"}, {"key": "limit", "value": "50"}]}, "description": "History drawer API \u2014 list threads for logged-in user."}, "response": []}, {"name": "Create Session", "request": {"auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{access_token}}", "type": "string"}]}, "method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\n \"product_id\": \"{{product_id}}\",\n \"agent_type\": \"{{agent_type}}\",\n \"channel\": \"user_app\",\n \"title\": \"Postman test chat\"\n}"}, "url": "{{base_url}}/v1/user-platform/ai/sessions", "description": "Optional explicit session create. Saves `session_id` from response."}, "response": []}, {"name": "Get Session (with messages)", "request": {"auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{access_token}}", "type": "string"}]}, "method": "GET", "header": [], "url": "{{base_url}}/v1/user-platform/ai/sessions/{{session_id}}", "description": "Resume chat \u2014 returns session + all messages."}, "response": []}, {"name": "Rename Session", "request": {"auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{access_token}}", "type": "string"}]}, "method": "PATCH", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\n \"title\": \"Renamed from Postman\"\n}"}, "url": "{{base_url}}/v1/user-platform/ai/sessions/{{session_id}}", "description": "Update session title."}, "response": []}, {"name": "Delete Session", "request": {"auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{access_token}}", "type": "string"}]}, "method": "DELETE", "header": [], "url": "{{base_url}}/v1/user-platform/ai/sessions/{{session_id}}", "description": "Soft delete \u2014 returns **204**."}, "response": []}]}, {"name": "03 User Feedback (user JWT)", "item": [{"name": "Submit Feedback (thumbs down)", "request": {"auth": {"type": "bearer", "bearer": [{"key": "token", "value": "{{access_token}}", "type": "string"}]}, "method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\n \"session_id\": \"{{session_id}}\",\n \"message_id\": \"{{message_id}}\",\n \"agent_type\": \"{{agent_type}}\",\n \"rating\": \"down\",\n \"comment\": \"Not accurate from Postman test\"\n}"}, "url": "{{base_url}}/v1/user-platform/ai/feedback", "description": "Persist feedback. Run Append Messages or Get Session first so `message_id` is set."}, "response": []}]}, {"name": "99 Keycloak Helpers (Setup / Debug)", "description": "Obtain JWTs before calling protected routes.", "item": [{"name": "Password Login (am-identity)", "event": [{"listen": "test", "script": {"type": "text/javascript", "exec": ["if (pm.response.code === 200) {", " const b = pm.response.json();", " if (b.access_token) {", " pm.environment.set('access_token', b.access_token);", " try {", " const payload = JSON.parse(atob(b.access_token.split('.')[1]));", " if (payload.sub) pm.environment.set('user_sub', payload.sub);", " } catch (e) {}", " }", "}"]}}], "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/json"}], "body": {"mode": "raw", "raw": "{\n \"username\": \"{{test_email}}\",\n \"password\": \"{{test_password}}\"\n}", "options": {"raw": {"language": "json"}}}, "url": "{{identity_base_url}}/identity/auth/login", "description": "User JWT via am-identity (am-web-client direct access grants are disabled in prod). Saves `access_token` and `user_sub`."}, "response": []}, {"name": "Client Credentials (am-gateway-client)", "event": [{"listen": "test", "script": {"type": "text/javascript", "exec": ["if (pm.response.code === 200) {", " const b = pm.response.json();", " if (b.access_token) {", " pm.environment.set('service_access_token', b.access_token);", " }", "}"]}}], "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/x-www-form-urlencoded"}], "body": {"mode": "urlencoded", "urlencoded": [{"key": "grant_type", "value": "client_credentials"}, {"key": "client_id", "value": "{{gateway_client_id}}"}, {"key": "client_secret", "value": "{{gateway_client_secret}}"}]}, "url": "{{keycloak_url}}/realms/{{keycloak_realm}}/protocol/openid-connect/token", "description": "Service token for `/internal/ai/*` (simulates gateway or fin-agent)."}, "response": []}, {"name": "Client Credentials (am-fin-agent-service)", "event": [{"listen": "test", "script": {"type": "text/javascript", "exec": ["if (pm.response.code === 200) {", " const b = pm.response.json();", " if (b.access_token) {", " pm.environment.set('service_access_token', b.access_token);", " }", "}"]}}], "request": {"method": "POST", "header": [{"key": "Content-Type", "value": "application/x-www-form-urlencoded"}], "body": {"mode": "urlencoded", "urlencoded": [{"key": "grant_type", "value": "client_credentials"}, {"key": "client_id", "value": "{{fin_agent_client_id}}"}, {"key": "client_secret", "value": "{{fin_agent_client_secret}}"}]}, "url": "{{keycloak_url}}/realms/{{keycloak_realm}}/protocol/openid-connect/token", "description": "Alternative service token (fin-portfolio-agent pod)."}, "response": []}]}], "event": [{"listen": "prerequest", "script": {"type": "text/javascript", "exec": ["if (!pm.environment.get('base_url')) {", " pm.environment.set('base_url', 'http://localhost:8115');", "}", "if (!pm.environment.get('session_id')) {", " pm.environment.set('session_id', pm.variables.replaceIn('{{$guid}}'));", "}"]}}, {"listen": "test", "script": {"type": "text/javascript", "exec": ["const ok = pm.response.code >= 200 && pm.response.code < 300;", "if (!ok) return;", "try {", " const body = pm.response.json();", " if (body.access_token) {", " pm.environment.set('access_token', body.access_token);", " }", " if (body.data) {", " const d = body.data;", " if (d.id && !d.session) {", " pm.environment.set('session_id', d.id);", " }", " if (d.session && d.session.id) {", " pm.environment.set('session_id', d.session.id);", " }", " if (d.user_id) {", " pm.environment.set('user_sub', d.user_id);", " }", " if (Array.isArray(d) && d.length && d[0].id) {", " pm.environment.set('message_id', d[0].id);", " }", " if (d.messages && d.messages.length) {", " const last = d.messages[d.messages.length - 1];", " if (last.id) pm.environment.set('message_id', last.id);", " }", " }", "} catch (e) {}"]}}], "variable": [{"key": "base_url", "value": "http://localhost:8115"}, {"key": "identity_base_url", "value": "https://am.asrax.in"}, {"key": "keycloak_url", "value": "https://auth.asrax.in/auth"}, {"key": "keycloak_realm", "value": "am-realm"}, {"key": "access_token", "value": ""}, {"key": "service_access_token", "value": ""}, {"key": "user_sub", "value": ""}, {"key": "session_id", "value": ""}, {"key": "message_id", "value": ""}, {"key": "product_id", "value": "am_app"}, {"key": "agent_type", "value": "fin_portfolio"}]} \ No newline at end of file diff --git a/am-user-platform/pyproject.toml b/am-user-platform/pyproject.toml new file mode 100644 index 0000000..76b9e4b --- /dev/null +++ b/am-user-platform/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "am-user-platform" +version = "0.1.0" +description = "User-facing platform state — AI chat memory, preferences, corp settings" +authors = [{ name = "AM Platform", email = "platform@asrax.in" }] +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.100.0", + "uvicorn>=0.30.0", + "httpx>=0.24.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "sqlalchemy[asyncio]>=2.0.0", + "asyncpg>=0.29.0", + "am-platform-common", + "am-platform-security", +] + +[tool.poetry] +packages = [{ include = "am_user_platform" }] + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/am-user-platform/scripts/fix_oidc_issuer_prod.py b/am-user-platform/scripts/fix_oidc_issuer_prod.py new file mode 100644 index 0000000..2b14a9f --- /dev/null +++ b/am-user-platform/scripts/fix_oidc_issuer_prod.py @@ -0,0 +1,94 @@ +"""Patch prod am-identity OIDC issuer/JWKS to auth.asrax.in (matches live Keycloak tokens).""" + +from __future__ import annotations + +import json +import pathlib +import subprocess +import tempfile +import urllib.request + +CREDS = pathlib.Path.home() / ".asrax" / "credentials.env" +PATH = "secret/prod/services/am-identity" +ISSUER = "https://auth.asrax.in/auth/realms/am-realm" +# Pod Python image fails TLS verify against the public cert; HTTP JWKS works in-cluster. +JWKS = "http://auth.asrax.in/auth/realms/am-realm/protocol/openid-connect/certs" + + +def load_vault_env() -> None: + import os + + for line in CREDS.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + if k.strip() in ("VAULT_ADDR", "VAULT_TOKEN"): + os.environ[k.strip()] = v.strip().strip('"') + + +def main() -> None: + import os + + load_vault_env() + existing = subprocess.check_output( + ["vault", "kv", "get", "-format=json", PATH], text=True + ) + data = json.loads(existing)["data"]["data"] + before_iss = data.get("OIDC_ISSUER", "") + data["OIDC_ISSUER"] = ISSUER + data["OIDC_JWKS_URL"] = JWKS + # keep discovery/token urls aligned if present + if "OIDC_DISCOVERY_URL" in data: + data["OIDC_DISCOVERY_URL"] = ( + "https://auth.asrax.in/auth/realms/am-realm/.well-known/openid-configuration" + ) + if "OIDC_TOKEN_URL" in data: + data["OIDC_TOKEN_URL"] = ( + "https://auth.asrax.in/auth/realms/am-realm/protocol/openid-connect/token" + ) + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: + json.dump(data, f) + tmp = f.name + try: + subprocess.check_call(["vault", "kv", "put", PATH, f"@{tmp}"]) + finally: + pathlib.Path(tmp).unlink(missing_ok=True) + print("OK patched", PATH) + print("OIDC_ISSUER before host:", before_iss.split("/")[2] if before_iss.startswith("http") else before_iss[:40]) + print("OIDC_ISSUER after host: auth.asrax.in") + # restart user-platform to reload injector env + subprocess.check_call( + [ + "kubectl", + "--kubeconfig", + str(pathlib.Path.home() / ".asrax" / "kubeconfig.vps"), + "--context", + "kind-am-preprod", + "-n", + "am-apps-prod", + "rollout", + "restart", + "deploy/am-user-platform", + ] + ) + subprocess.check_call( + [ + "kubectl", + "--kubeconfig", + str(pathlib.Path.home() / ".asrax" / "kubeconfig.vps"), + "--context", + "kind-am-preprod", + "-n", + "am-apps-prod", + "rollout", + "status", + "deploy/am-user-platform", + "--timeout=180s", + ] + ) + print("OK rollout complete") + + +if __name__ == "__main__": + main() diff --git a/am-user-platform/scripts/fix_postman_browser.py b/am-user-platform/scripts/fix_postman_browser.py new file mode 100644 index 0000000..3768d67 --- /dev/null +++ b/am-user-platform/scripts/fix_postman_browser.py @@ -0,0 +1,627 @@ +"""Rebuild Postman collection + browser-safe prod env; never print secrets.""" + +from __future__ import annotations + +import json +import pathlib +import urllib.error +import urllib.parse +import urllib.request +import uuid + +HOME = pathlib.Path.home() +CREDS = HOME / ".asrax" / "credentials.env" +ROOT = pathlib.Path(__file__).resolve().parents[1] +POSTMAN_DIR = ROOT / "postman" +WORKSPACE = "c1ed9ba4-e485-4377-8374-7685abf4247d" +COLLECTION_UID = "56761657-a9fa5747-49c7-487e-8091-da3262f5a944" +PORT_FORWARD_ENV_UID = "56761657-3f6f40c7-80f0-4a72-9b6d-344dfeb183c3" +UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + + +def load_creds() -> dict[str, str]: + out: dict[str, str] = {} + for line in CREDS.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip('"') + return out + + +def http_json(method: str, url: str, headers: dict, body: dict | None = None): + data = None if body is None else json.dumps(body).encode() + req = urllib.request.Request(url, data=data, headers={**headers, "User-Agent": UA}, method=method) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + raw = resp.read().decode() + return resp.status, json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + try: + return e.code, json.loads(raw) if raw else {} + except json.JSONDecodeError: + return e.code, {"raw": raw[:400]} + + +def admin_token(creds: dict) -> tuple[str, str]: + kc = (creds.get("KEYCLOAK_URL") or "https://auth.asrax.in/auth").rstrip("/") + form = urllib.parse.urlencode( + { + "grant_type": "password", + "client_id": "admin-cli", + "username": creds["KEYCLOAK_ADMIN"], + "password": creds["KEYCLOAK_ADMIN_PASSWORD"], + } + ).encode() + code, body = http_json( + "POST", + f"{kc}/realms/master/protocol/openid-connect/token", + {"Content-Type": "application/x-www-form-urlencoded"}, + None, + ) + # http_json with form needs raw body — special-case + req = urllib.request.Request( + f"{kc}/realms/master/protocol/openid-connect/token", + data=form, + headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": UA}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=60) as resp: + tok = json.loads(resp.read().decode())["access_token"] + return kc, tok + + +def client_secret(kc: str, token: str, client_id: str) -> str: + code, clients = http_json( + "GET", + f"{kc}/admin/realms/am-realm/clients?clientId={urllib.parse.quote(client_id)}&max=1", + {"Authorization": f"Bearer {token}"}, + ) + if code != 200 or not isinstance(clients, list) or not clients: + raise SystemExit(f"client {client_id} not found HTTP {code}") + cid = clients[0]["id"] + code, secret = http_json( + "GET", + f"{kc}/admin/realms/am-realm/clients/{cid}/client-secret", + {"Authorization": f"Bearer {token}"}, + ) + if code != 200 or not isinstance(secret, dict) or not secret.get("value"): + raise SystemExit(f"secret for {client_id} failed HTTP {code}") + return secret["value"] + + +def reset_test_password(kc: str, token: str, email: str) -> str: + q = urllib.parse.urlencode({"email": email, "exact": "true"}) + code, users = http_json( + "GET", + f"{kc}/admin/realms/am-realm/users?{q}", + {"Authorization": f"Bearer {token}"}, + ) + if code != 200 or not isinstance(users, list) or not users: + raise SystemExit(f"user {email} not found") + uid = users[0]["id"] + pw = "Tmp" + uuid.uuid4().hex[:12] + "9A" + code, _ = http_json( + "PUT", + f"{kc}/admin/realms/am-realm/users/{uid}/reset-password", + {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + {"type": "password", "value": pw, "temporary": False}, + ) + if code not in (200, 204): + raise SystemExit(f"reset password HTTP {code}") + return pw + + +def bearer_auth(token_var: str) -> dict: + return { + "type": "bearer", + "bearer": [{"key": "token", "value": f"{{{{{token_var}}}}}", "type": "string"}], + } + + +def req(name: str, method: str, url: str, **kwargs) -> dict: + out: dict = { + "name": name, + "request": { + "method": method, + "header": kwargs.get("header", []), + "url": url, + "description": kwargs.get("description", ""), + }, + "response": [], + } + if "auth" in kwargs: + out["request"]["auth"] = kwargs["auth"] + if "body" in kwargs: + out["request"]["body"] = kwargs["body"] + if "event" in kwargs: + out["event"] = kwargs["event"] + return out + + +def build_collection() -> dict: + login_tests = [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const b = pm.response.json();", + " if (b.access_token) {", + " pm.environment.set('access_token', b.access_token);", + " try {", + " const payload = JSON.parse(atob(b.access_token.split('.')[1]));", + " if (payload.sub) pm.environment.set('user_sub', payload.sub);", + " } catch (e) {}", + " }", + "}", + ], + }, + } + ] + svc_tests = [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " const b = pm.response.json();", + " if (b.access_token) pm.environment.set('service_access_token', b.access_token);", + "}", + ], + }, + } + ] + return { + "info": { + "name": "AM User Platform Service", + "description": ( + "AI chat memory APIs for **am-user-platform** + gateway proxies.\n\n" + "## Browser Postman (no desktop install)\n" + "1. Top-right: select **AM User Platform — Prod Public (Browser)**\n" + "2. Run **00 Auth** → Password Login, then Client Credentials (gateway)\n" + "3. Run **02 User Sessions (public)** and **03 Gateway Sessions**\n\n" + "**Browser cannot call localhost.** Health + Internal need laptop port-forward " + "(`base_url=http://127.0.0.1:8115`) — use env **Prod Port-Forward** only on desktop " + "or leave them to agent smoke scripts.\n\n" + "| Folder | Works in browser? |\n|--------|-------------------|\n" + "| 00 Auth | yes |\n| 01 Health / Internal | only with port-forward |\n" + "| 02 User Sessions (public) | yes |\n| 03 Gateway Sessions | yes (what Flutter will call) |\n" + "| 04 Feedback | yes |\n" + ), + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "exec": [ + "if (!pm.environment.get('session_id')) {", + " pm.environment.set('session_id', pm.variables.replaceIn('{{$guid}}'));", + "}", + ], + }, + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code < 200 || pm.response.code >= 300) return;", + "try {", + " const body = pm.response.json();", + " if (body.data) {", + " const d = body.data;", + " if (d.id && !d.session) pm.environment.set('session_id', d.id);", + " if (d.session && d.session.id) pm.environment.set('session_id', d.session.id);", + " if (Array.isArray(d) && d.length && d[d.length-1].id) {", + " pm.environment.set('message_id', d[d.length-1].id);", + " }", + " if (d.messages && d.messages.length) {", + " const last = d.messages[d.messages.length-1];", + " if (last.id) pm.environment.set('message_id', last.id);", + " }", + " }", + "} catch (e) {}", + ], + }, + }, + ], + "variable": [ + {"key": "public_base_url", "value": "https://am.asrax.in"}, + {"key": "gateway_url", "value": "https://am.asrax.in/ai"}, + {"key": "identity_base_url", "value": "https://am.asrax.in"}, + {"key": "keycloak_url", "value": "https://auth.asrax.in/auth"}, + {"key": "keycloak_realm", "value": "am-realm"}, + {"key": "base_url", "value": "http://127.0.0.1:8115"}, + {"key": "product_id", "value": "am_app"}, + {"key": "agent_type", "value": "fin_portfolio"}, + ], + "item": [ + { + "name": "00 Auth (run first)", + "item": [ + req( + "Password Login (am-identity)", + "POST", + "{{identity_base_url}}/identity/auth/login", + header=[{"key": "Content-Type", "value": "application/json"}], + body={ + "mode": "raw", + "raw": '{\n "username": "{{test_email}}",\n "password": "{{test_password}}"\n}', + "options": {"raw": {"language": "json"}}, + }, + event=login_tests, + description="User JWT. Saves access_token + user_sub. Use this in browser.", + ), + req( + "Client Credentials (am-gateway-client)", + "POST", + "{{keycloak_url}}/realms/{{keycloak_realm}}/protocol/openid-connect/token", + header=[{"key": "Content-Type", "value": "application/x-www-form-urlencoded"}], + body={ + "mode": "urlencoded", + "urlencoded": [ + {"key": "grant_type", "value": "client_credentials"}, + {"key": "client_id", "value": "{{gateway_client_id}}"}, + {"key": "client_secret", "value": "{{gateway_client_secret}}"}, + ], + }, + event=svc_tests, + description="Service JWT for /internal/* (port-forward only).", + ), + req( + "Client Credentials (am-fin-agent-service)", + "POST", + "{{keycloak_url}}/realms/{{keycloak_realm}}/protocol/openid-connect/token", + header=[{"key": "Content-Type", "value": "application/x-www-form-urlencoded"}], + body={ + "mode": "urlencoded", + "urlencoded": [ + {"key": "grant_type", "value": "client_credentials"}, + {"key": "client_id", "value": "{{fin_agent_client_id}}"}, + {"key": "client_secret", "value": "{{fin_agent_client_secret}}"}, + ], + }, + event=svc_tests, + description="Optional alternate service JWT. Needs fin_agent_client_secret in env.", + ), + ], + }, + { + "name": "01 Health + Internal (port-forward only)", + "description": "Browser Postman cannot reach 127.0.0.1. Use desktop + kubectl port-forward, or skip.", + "item": [ + req("GET Health", "GET", "{{base_url}}/health"), + req("GET Ready", "GET", "{{base_url}}/ready"), + req( + "POST Append Messages (no auth → 401)", + "POST", + "{{base_url}}/internal/ai/sessions/{{session_id}}/messages", + header=[{"key": "Content-Type", "value": "application/json"}], + body={ + "mode": "raw", + "raw": '{\n "user_id": "{{user_sub}}",\n "product_id": "{{product_id}}",\n "agent_type": "{{agent_type}}",\n "messages": [{"role": "user", "content": "noauth"}]\n}', + }, + ), + req( + "POST Append Messages", + "POST", + "{{base_url}}/internal/ai/sessions/{{session_id}}/messages", + auth=bearer_auth("service_access_token"), + header=[{"key": "Content-Type", "value": "application/json"}], + body={ + "mode": "raw", + "raw": ( + '{\n "user_id": "{{user_sub}}",\n "product_id": "{{product_id}}",\n' + ' "agent_type": "{{agent_type}}",\n "channel": "user_app",\n' + ' "messages": [\n {"role": "user", "content": "Show portfolio"},\n' + ' {"role": "assistant", "content": "Here is a summary", "tokens_used": 100}\n ]\n}' + ), + }, + ), + req( + "GET Session Context", + "GET", + "{{base_url}}/internal/ai/sessions/{{session_id}}/context?user_id={{user_sub}}&limit=20", + auth=bearer_auth("service_access_token"), + ), + ], + }, + { + "name": "02 User Sessions (public — browser OK)", + "description": "https://am.asrax.in/v1/user-platform — works in browser Postman with user JWT.", + "item": [ + req( + "GET List Sessions", + "GET", + "{{public_base_url}}/v1/user-platform/ai/sessions?product_id={{product_id}}&agent_type={{agent_type}}", + auth=bearer_auth("access_token"), + ), + req( + "POST Create Session", + "POST", + "{{public_base_url}}/v1/user-platform/ai/sessions", + auth=bearer_auth("access_token"), + header=[{"key": "Content-Type", "value": "application/json"}], + body={ + "mode": "raw", + "raw": ( + '{\n "product_id": "{{product_id}}",\n "agent_type": "{{agent_type}}",\n' + ' "title": "Postman browser session"\n}' + ), + }, + ), + req( + "GET Session Detail", + "GET", + "{{public_base_url}}/v1/user-platform/ai/sessions/{{session_id}}", + auth=bearer_auth("access_token"), + ), + req( + "PATCH Rename Session", + "PATCH", + "{{public_base_url}}/v1/user-platform/ai/sessions/{{session_id}}", + auth=bearer_auth("access_token"), + header=[{"key": "Content-Type", "value": "application/json"}], + body={"mode": "raw", "raw": '{\n "title": "Renamed in Postman"\n}'}, + ), + req( + "DELETE Session", + "DELETE", + "{{public_base_url}}/v1/user-platform/ai/sessions/{{session_id}}", + auth=bearer_auth("access_token"), + ), + ], + }, + { + "name": "03 Gateway Sessions (Flutter path — browser OK)", + "description": "https://am.asrax.in/ai/v1/ai/sessions* — same APIs the app will call.", + "item": [ + req( + "GET Gateway Health", + "GET", + "{{gateway_url}}/v1/ai/health", + ), + req( + "GET List Sessions via Gateway", + "GET", + "{{gateway_url}}/v1/ai/sessions?product_id={{product_id}}&agent_type={{agent_type}}", + auth=bearer_auth("access_token"), + ), + req( + "POST Create Session via Gateway", + "POST", + "{{gateway_url}}/v1/ai/sessions", + auth=bearer_auth("access_token"), + header=[{"key": "Content-Type", "value": "application/json"}], + body={ + "mode": "raw", + "raw": ( + '{\n "product_id": "{{product_id}}",\n "agent_type": "{{agent_type}}",\n' + ' "title": "Gateway Postman session"\n}' + ), + }, + ), + req( + "GET Session via Gateway", + "GET", + "{{gateway_url}}/v1/ai/sessions/{{session_id}}", + auth=bearer_auth("access_token"), + ), + req( + "PATCH Rename via Gateway", + "PATCH", + "{{gateway_url}}/v1/ai/sessions/{{session_id}}", + auth=bearer_auth("access_token"), + header=[{"key": "Content-Type", "value": "application/json"}], + body={"mode": "raw", "raw": '{\n "title": "Gateway renamed"\n}'}, + ), + req( + "DELETE Session via Gateway", + "DELETE", + "{{gateway_url}}/v1/ai/sessions/{{session_id}}", + auth=bearer_auth("access_token"), + ), + ], + }, + { + "name": "04 Feedback (browser OK)", + "item": [ + req( + "POST Feedback (public user-platform)", + "POST", + "{{public_base_url}}/v1/user-platform/ai/feedback", + auth=bearer_auth("access_token"), + header=[{"key": "Content-Type", "value": "application/json"}], + body={ + "mode": "raw", + "raw": ( + '{\n "session_id": "{{session_id}}",\n "agent_type": "{{agent_type}}",\n' + ' "rating": "down",\n "comment": "postman"\n}' + ), + }, + ), + req( + "POST Feedback via Gateway", + "POST", + "{{gateway_url}}/v1/ai/feedback", + auth=bearer_auth("access_token"), + header=[{"key": "Content-Type", "value": "application/json"}], + body={ + "mode": "raw", + "raw": ( + '{\n "sessionId": "{{session_id}}",\n "rating": "thumbs_down",\n' + ' "comment": "gateway postman"\n}' + ), + }, + ), + ], + }, + ], + } + + +def env_values( + *, + name_hint: str, + base_url: str, + gateway_secret: str, + fin_secret: str, + test_email: str, + test_password: str, +) -> list[dict]: + return [ + {"enabled": True, "key": "public_base_url", "value": "https://am.asrax.in", "type": "default"}, + {"enabled": True, "key": "gateway_url", "value": "https://am.asrax.in/ai", "type": "default"}, + {"enabled": True, "key": "identity_base_url", "value": "https://am.asrax.in", "type": "default"}, + {"enabled": True, "key": "keycloak_url", "value": "https://auth.asrax.in/auth", "type": "default"}, + {"enabled": True, "key": "keycloak_realm", "value": "am-realm", "type": "default"}, + {"enabled": True, "key": "base_url", "value": base_url, "type": "default"}, + {"enabled": True, "key": "gateway_client_id", "value": "am-gateway-client", "type": "default"}, + {"enabled": True, "key": "gateway_client_secret", "value": gateway_secret, "type": "secret"}, + {"enabled": True, "key": "fin_agent_client_id", "value": "am-fin-agent-service", "type": "default"}, + {"enabled": True, "key": "fin_agent_client_secret", "value": fin_secret, "type": "secret"}, + {"enabled": True, "key": "test_email", "value": test_email, "type": "default"}, + {"enabled": True, "key": "test_password", "value": test_password, "type": "secret"}, + {"enabled": True, "key": "access_token", "value": "", "type": "secret"}, + {"enabled": True, "key": "service_access_token", "value": "", "type": "secret"}, + {"enabled": True, "key": "user_sub", "value": "", "type": "default"}, + {"enabled": True, "key": "session_id", "value": "", "type": "default"}, + {"enabled": True, "key": "message_id", "value": "", "type": "default"}, + {"enabled": True, "key": "product_id", "value": "am_app", "type": "default"}, + {"enabled": True, "key": "agent_type", "value": "fin_portfolio", "type": "default"}, + {"enabled": True, "key": "env_note", "value": name_hint, "type": "default"}, + ] + + +def main() -> None: + creds = load_creds() + api_key = creds.get("POSTMAN_API_KEY") + if not api_key: + raise SystemExit("POSTMAN_API_KEY missing") + kc, tok = admin_token(creds) + gw_secret = client_secret(kc, tok, "am-gateway-client") + fin_secret = client_secret(kc, tok, "am-fin-agent-service") + email = "test.user@example.com" + password = reset_test_password(kc, tok, email) + print("ok secrets fetched (not printed)") + + collection = build_collection() + POSTMAN_DIR.mkdir(parents=True, exist_ok=True) + (POSTMAN_DIR / "AM-User-Platform.postman_collection.json").write_text( + json.dumps(collection, indent=2), encoding="utf-8" + ) + + pm_headers = {"X-Api-Key": api_key, "Content-Type": "application/json"} + + # Update collection (put needs id fields stripped for recreate — use Prefer async) + # Fetch existing to preserve IDs is hard; put without item ids recreates. + code, body = http_json( + "PUT", + f"https://api.getpostman.com/collections/{COLLECTION_UID}", + pm_headers, + {"collection": collection}, + ) + print(f"ok put collection HTTP {code}") + + # Update port-forward env (keep for laptop) + code, body = http_json( + "PUT", + f"https://api.getpostman.com/environments/{PORT_FORWARD_ENV_UID}", + pm_headers, + { + "environment": { + "name": "AM User Platform — Prod Port-Forward", + "values": env_values( + name_hint="laptop+kubectl port-forward 8115", + base_url="http://127.0.0.1:8115", + gateway_secret=gw_secret, + fin_secret=fin_secret, + test_email=email, + test_password=password, + ), + } + }, + ) + print(f"ok put port-forward env HTTP {code}") + + # Create / replace browser env + browser_name = "AM User Platform — Prod Public (Browser)" + code, envs = http_json( + "GET", + f"https://api.getpostman.com/environments?workspace={WORKSPACE}", + {"X-Api-Key": api_key}, + ) + browser_uid = None + if code == 200 and isinstance(envs, dict): + for e in envs.get("environments") or []: + if e.get("name") == browser_name: + browser_uid = e.get("uid") + break + browser_payload = { + "environment": { + "name": browser_name, + "values": env_values( + name_hint="browser Postman — use public + gateway URLs", + base_url="https://am.asrax.in", # health won't work; user routes use public_base_url + gateway_secret=gw_secret, + fin_secret=fin_secret, + test_email=email, + test_password=password, + ), + } + } + if browser_uid: + code, body = http_json( + "PUT", + f"https://api.getpostman.com/environments/{browser_uid}", + pm_headers, + browser_payload, + ) + print(f"ok put browser env HTTP {code} uid={browser_uid}") + else: + code, body = http_json( + "POST", + f"https://api.getpostman.com/environments?workspace={WORKSPACE}", + pm_headers, + browser_payload, + ) + browser_uid = (body.get("environment") or {}).get("uid") if isinstance(body, dict) else None + print(f"ok create browser env HTTP {code} uid={browser_uid}") + + ids = { + "collection_uid": COLLECTION_UID, + "environment_uid_port_forward": PORT_FORWARD_ENV_UID, + "environment_uid_browser": browser_uid, + "workspace": WORKSPACE, + "test_email": email, + } + (POSTMAN_DIR / "_postman_ids.json").write_text(json.dumps(ids, indent=2), encoding="utf-8") + (POSTMAN_DIR / "AM-User-Platform.browser.postman_environment.json").write_text( + json.dumps( + { + "id": "am-user-platform-browser-env", + "name": browser_name, + "values": [ + {**v, "value": "" if v["type"] == "secret" else v["value"]} + for v in browser_payload["environment"]["values"] + ], + }, + indent=2, + ), + encoding="utf-8", + ) + print("DONE — select browser env in Postman top-right, then run 00 Auth") + + +if __name__ == "__main__": + main() diff --git a/am-user-platform/scripts/matrix_user_platform.py b/am-user-platform/scripts/matrix_user_platform.py new file mode 100644 index 0000000..3d2fd58 --- /dev/null +++ b/am-user-platform/scripts/matrix_user_platform.py @@ -0,0 +1,379 @@ +"""Full user-platform endpoint matrix (port-forward + public ingress). Never prints secrets.""" + +from __future__ import annotations + +import base64 +import json +import os +import pathlib +import urllib.error +import urllib.parse +import urllib.request +import uuid + +CREDS = pathlib.Path.home() / ".asrax" / "credentials.env" +PF = os.environ.get("BASE_URL", "http://127.0.0.1:8115").rstrip("/") +PUBLIC = os.environ.get("PUBLIC_BASE_URL", "https://am.asrax.in").rstrip("/") +KEYCLOAK = os.environ.get("KEYCLOAK_URL", "https://auth.asrax.in/auth").rstrip("/") +REALM = os.environ.get("KEYCLOAK_REALM", "am-realm") +UA = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" +) + + +def load_creds() -> dict[str, str]: + out: dict[str, str] = {} + if CREDS.exists(): + for line in CREDS.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip('"') + return out + + +def req(method: str, url: str, headers: dict | None = None, data: bytes | None = None): + hdrs = {"User-Agent": UA, "Accept": "application/json"} + if headers: + hdrs.update(headers) + r = urllib.request.Request(url, data=data, headers=hdrs, method=method) + try: + with urllib.request.urlopen(r, timeout=30) as resp: + raw = resp.read().decode() + try: + return resp.status, json.loads(raw) if raw else {} + except json.JSONDecodeError: + return resp.status, raw + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + try: + return e.code, json.loads(raw) if raw else {} + except json.JSONDecodeError: + return e.code, raw + except Exception as e: + return 0, {"error": type(e).__name__, "msg": str(e)[:120]} + + +def token_form_admin(kc_url: str, username: str, password: str) -> str: + data = urllib.parse.urlencode( + { + "grant_type": "password", + "client_id": "admin-cli", + "username": username, + "password": password, + } + ).encode() + code, body = req( + "POST", + f"{kc_url}/realms/master/protocol/openid-connect/token", + {"Content-Type": "application/x-www-form-urlencoded"}, + data, + ) + if code != 200 or not isinstance(body, dict) or not body.get("access_token"): + raise RuntimeError(f"admin token HTTP {code}") + return body["access_token"] + + +def fetch_client_secret(kc_url: str, admin_token: str, client_id: str) -> str: + code, body = req( + "GET", + f"{kc_url}/admin/realms/{REALM}/clients?clientId={urllib.parse.quote(client_id)}&max=1", + {"Authorization": f"Bearer {admin_token}"}, + ) + if code != 200 or not isinstance(body, list) or not body: + raise RuntimeError(f"client lookup HTTP {code}") + client_uuid = body[0]["id"] + code, secret_obj = req( + "GET", + f"{kc_url}/admin/realms/{REALM}/clients/{client_uuid}/client-secret", + {"Authorization": f"Bearer {admin_token}"}, + ) + if code != 200 or not isinstance(secret_obj, dict): + raise RuntimeError(f"client secret HTTP {code}") + return secret_obj.get("value") or "" + + +def rotate_test_user_password(kc_url: str, admin_token: str, email: str) -> str: + q = urllib.parse.urlencode({"email": email, "exact": "true"}) + code, users = req( + "GET", + f"{kc_url}/admin/realms/{REALM}/users?{q}", + {"Authorization": f"Bearer {admin_token}"}, + ) + if code != 200 or not isinstance(users, list) or not users: + raise RuntimeError(f"user lookup HTTP {code}") + user_id = users[0]["id"] + new_pw = "Tmp" + uuid.uuid4().hex[:12] + "9A" + payload = json.dumps( + {"type": "password", "value": new_pw, "temporary": False} + ).encode() + code, _ = req( + "PUT", + f"{kc_url}/admin/realms/{REALM}/users/{user_id}/reset-password", + { + "Authorization": f"Bearer {admin_token}", + "Content-Type": "application/json", + }, + payload, + ) + if code not in (204, 200): + raise RuntimeError(f"reset-password HTTP {code}") + return new_pw + + +def jwt_sub(token: str) -> str: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload))["sub"] + + +def main() -> None: + creds = load_creds() + kc_url = (os.environ.get("KEYCLOAK_URL") or creds.get("KEYCLOAK_URL") or KEYCLOAK).rstrip("/") + kc_admin = creds.get("KEYCLOAK_ADMIN") or "" + kc_pass = creds.get("KEYCLOAK_ADMIN_PASSWORD") or "" + if not kc_admin or not kc_pass: + raise SystemExit("FAIL: KEYCLOAK_ADMIN missing in credentials.env") + + admin_token = token_form_admin(kc_url, kc_admin, kc_pass) + gw_secret = fetch_client_secret(kc_url, admin_token, "am-gateway-client") + email = os.environ.get("TEST_EMAIL") or "test.user@example.com" + password = os.environ.get("TEST_PASSWORD") or rotate_test_user_password( + kc_url, admin_token, email + ) + if not gw_secret or not password: + raise SystemExit("FAIL: could not obtain gateway secret or test user password") + + passed = 0 + total = 0 + + def run(name: str, cond: bool, detail: str = "") -> None: + nonlocal passed, total + total += 1 + print(f"[{'pass' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) + if cond: + passed += 1 + + code, body = req("GET", f"{PF}/health") + run("GET /health", code == 200 and isinstance(body, dict) and body.get("status") == "ok") + + code, body = req("GET", f"{PF}/health/live") + run("GET /health/live", code == 200 and isinstance(body, dict) and body.get("status") == "ok") + + code, body = req("GET", f"{PF}/ready") + run( + "GET /ready database=true", + code == 200 and isinstance(body, dict) and body.get("database") is True, + f"HTTP {code}", + ) + + sid = str(uuid.uuid4()) + code, _ = req( + "POST", + f"{PF}/internal/ai/sessions/{sid}/messages", + {"Content-Type": "application/json"}, + json.dumps( + { + "user_id": "x", + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [{"role": "user", "content": "noauth"}], + } + ).encode(), + ) + run("internal append no auth → 401", code == 401, f"got {code}") + + code, _ = req("GET", f"{PF}/v1/user-platform/ai/sessions") + run("user list no auth → 401", code == 401, f"got {code}") + + token_body = urllib.parse.urlencode( + { + "grant_type": "client_credentials", + "client_id": "am-gateway-client", + "client_secret": gw_secret, + } + ).encode() + code, body = req( + "POST", + f"{KEYCLOAK}/realms/{REALM}/protocol/openid-connect/token", + {"Content-Type": "application/x-www-form-urlencoded"}, + token_body, + ) + svc = body.get("access_token") if isinstance(body, dict) else None + run("gateway client credentials", bool(svc), f"HTTP {code}") + + code, body = req( + "POST", + f"{PUBLIC}/identity/auth/login", + {"Content-Type": "application/json"}, + json.dumps({"username": email, "password": password}).encode(), + ) + user = body.get("access_token") if isinstance(body, dict) else None + sub = jwt_sub(user) if user else "" + run("identity login", bool(user and sub), f"HTTP {code}") + + auth_svc = {"Authorization": f"Bearer {svc}", "Content-Type": "application/json"} if svc else {} + auth_user = {"Authorization": f"Bearer {user}", "Content-Type": "application/json"} if user else {} + other_id = f"other-{uuid.uuid4()}" + other_sid = str(uuid.uuid4()) + message_id = None + + if svc and sub: + code, body = req( + "POST", + f"{PF}/internal/ai/sessions/{sid}/messages", + auth_svc, + json.dumps( + { + "user_id": sub, + "product_id": "am_app", + "agent_type": "fin_portfolio", + "channel": "user_app", + "messages": [ + {"role": "user", "content": "matrix user Q"}, + { + "role": "assistant", + "content": "matrix assistant A", + "tokens_used": 42, + }, + ], + } + ).encode(), + ) + run("POST /internal/ai/sessions/{id}/messages", code in (200, 201), f"HTTP {code}") + if isinstance(body, dict) and isinstance(body.get("data"), list) and body["data"]: + message_id = body["data"][-1].get("id") + + q = urllib.parse.urlencode({"user_id": sub, "limit": "20"}) + code, body = req( + "GET", + f"{PF}/internal/ai/sessions/{sid}/context?{q}", + {"Authorization": f"Bearer {svc}"}, + ) + n = 0 + if isinstance(body, dict): + d = body.get("data") or {} + n = len(d.get("messages") or []) if isinstance(d, dict) else 0 + run("GET /internal/ai/sessions/{id}/context", code == 200 and n >= 2, f"HTTP {code} msgs={n}") + + other_sid = str(uuid.uuid4()) + code, _ = req( + "POST", + f"{PF}/internal/ai/sessions/{other_sid}/messages", + auth_svc, + json.dumps( + { + "user_id": other_id, + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [{"role": "user", "content": "secret"}], + } + ).encode(), + ) + run("seed other-user session", code in (200, 201), f"HTTP {code}") + + if user: + code, body = req( + "POST", + f"{PF}/v1/user-platform/ai/sessions", + auth_user, + json.dumps( + { + "product_id": "am_app", + "agent_type": "fin_portfolio", + "title": "Matrix created", + } + ).encode(), + ) + created_id = None + if isinstance(body, dict): + created_id = (body.get("data") or {}).get("id") + run("POST /v1/user-platform/ai/sessions", code == 201 and bool(created_id), f"HTTP {code}") + + code, _ = req( + "GET", + f"{PF}/v1/user-platform/ai/sessions?product_id=am_app&agent_type=fin_portfolio", + {"Authorization": f"Bearer {user}"}, + ) + run("GET list sessions (PF)", code == 200, f"HTTP {code}") + + code, _ = req( + "GET", + f"{PUBLIC}/v1/user-platform/ai/sessions?product_id=am_app&agent_type=fin_portfolio", + {"Authorization": f"Bearer {user}"}, + ) + run("GET list sessions (public ingress)", code == 200, f"HTTP {code}") + + code, body = req("GET", f"{PF}/v1/user-platform/ai/sessions/{sid}", {"Authorization": f"Bearer {user}"}) + msgs = 0 + if isinstance(body, dict): + msgs = len((body.get("data") or {}).get("messages") or []) + run("GET session detail", code == 200 and msgs >= 2, f"HTTP {code} msgs={msgs}") + + code, body = req( + "PATCH", + f"{PF}/v1/user-platform/ai/sessions/{sid}", + auth_user, + json.dumps({"title": "Matrix renamed"}).encode(), + ) + title_ok = isinstance(body, dict) and (body.get("data") or {}).get("title") == "Matrix renamed" + run("PATCH rename session", code == 200 and title_ok, f"HTTP {code}") + + fb = { + "session_id": sid, + "agent_type": "fin_portfolio", + "rating": "down", + "comment": "matrix", + } + if message_id: + fb["message_id"] = message_id + code, _ = req("POST", f"{PF}/v1/user-platform/ai/feedback", auth_user, json.dumps(fb).encode()) + run("POST feedback", code in (200, 201), f"HTTP {code}") + + code, _ = req( + "GET", + f"{PF}/v1/user-platform/ai/sessions/{other_sid}", + {"Authorization": f"Bearer {user}"}, + ) + run("tenancy GET other user session → 404", code == 404, f"got {code}") + + code, _ = req( + "DELETE", + f"{PF}/v1/user-platform/ai/sessions/{other_sid}", + {"Authorization": f"Bearer {user}"}, + ) + run("tenancy DELETE other user session → 404", code == 404, f"got {code}") + + if created_id: + code, _ = req( + "DELETE", + f"{PF}/v1/user-platform/ai/sessions/{created_id}", + {"Authorization": f"Bearer {user}"}, + ) + run("DELETE own session", code == 204, f"HTTP {code}") + code, _ = req( + "GET", + f"{PF}/v1/user-platform/ai/sessions/{created_id}", + {"Authorization": f"Bearer {user}"}, + ) + run("GET after DELETE → 404", code == 404, f"got {code}") + + if svc and other_id: + code, body = req( + "DELETE", + f"{PF}/internal/ai/users/{other_id}/data", + {"Authorization": f"Bearer {svc}"}, + ) + run("DELETE /internal/ai/users/{id}/data", code == 200, f"HTTP {code}") + + print() + print(f"MATRIX: {passed}/{total} passed") + if passed != total: + raise SystemExit(2) + print("ALL CHECKS PASSED") + + +if __name__ == "__main__": + main() diff --git a/am-user-platform/scripts/postman_upload_and_smoke.py b/am-user-platform/scripts/postman_upload_and_smoke.py new file mode 100644 index 0000000..bf3a3f3 --- /dev/null +++ b/am-user-platform/scripts/postman_upload_and_smoke.py @@ -0,0 +1,461 @@ +"""Upload AM User Platform collection/env to Postman and smoke-test via port-forward. + +Never prints secret values. Requires: + - POSTMAN_API_KEY in ~/.asrax/credentials.env + - Keycloak admin in credentials.env (to fetch gateway client secret) + - kubectl port-forward already listening on 127.0.0.1:8115 (or starts checks only) +""" + +from __future__ import annotations + +import base64 +import json +import pathlib +import sys +import urllib.error +import urllib.parse +import urllib.request +import uuid + +HOME = pathlib.Path.home() +CREDS = HOME / ".asrax" / "credentials.env" +WORKSPACE = "c1ed9ba4-e485-4377-8374-7685abf4247d" +COLLECTION_JSON = ( + pathlib.Path(__file__).resolve().parents[1] + / "postman" + / "_upload_payload.json" +) +IDS_OUT = pathlib.Path(__file__).resolve().parents[1] / "postman" / "_postman_ids.json" +BASE = "http://127.0.0.1:8115" +KC_REALM = "am-realm" + + +def load_creds() -> dict[str, str]: + out: dict[str, str] = {} + for line in CREDS.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + out[k.strip()] = v.strip().strip('"') + return out + + +def http_json(method: str, url: str, headers: dict, body: dict | None = None) -> dict: + data = None if body is None else json.dumps(body).encode("utf-8") + req = urllib.request.Request(url, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + raw = resp.read().decode() + return json.loads(raw) if raw else {} + except urllib.error.HTTPError as e: + err = e.read().decode(errors="replace") + raise RuntimeError(f"{method} {url} -> {e.code}: {err[:400]}") from e + + +def ok(msg: str) -> None: + print(f"[ok] {msg}") + + +def fail(msg: str) -> None: + print(f"[fail] {msg}") + raise SystemExit(1) + + +def main() -> None: + creds = load_creds() + api_key = creds.get("POSTMAN_API_KEY") or creds.get("POSTMAN_KEY") + if not api_key: + fail("POSTMAN_API_KEY missing") + + kc_url = (creds.get("KEYCLOAK_URL") or "http://auth.munish.org/auth").rstrip("/") + kc_admin = creds.get("KEYCLOAK_ADMIN") + kc_pass = creds.get("KEYCLOAK_ADMIN_PASSWORD") + if not kc_admin or not kc_pass: + fail("KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD missing") + + # Gateway client secret via Keycloak admin + form = urllib.parse.urlencode( + { + "grant_type": "password", + "client_id": "admin-cli", + "username": kc_admin, + "password": kc_pass, + } + ).encode() + req = urllib.request.Request( + f"{kc_url}/realms/master/protocol/openid-connect/token", + data=form, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=60) as resp: + token = json.loads(resp.read().decode())["access_token"] + hdr = {"Authorization": f"Bearer {token}"} + clients = http_json( + "GET", + f"{kc_url}/admin/realms/{KC_REALM}/clients?clientId=am-gateway-client&max=1", + hdr, + ) + if not clients: + fail("am-gateway-client not found") + client_uuid = clients[0]["id"] + secret_obj = http_json( + "GET", + f"{kc_url}/admin/realms/{KC_REALM}/clients/{client_uuid}/client-secret", + hdr, + ) + gateway_secret = secret_obj.get("value") + if not gateway_secret: + fail("empty gateway client secret") + ok("fetched am-gateway-client secret from Keycloak") + + collection = json.loads(COLLECTION_JSON.read_text(encoding="utf-8")) + created = http_json( + "POST", + f"https://api.getpostman.com/collections?workspace={WORKSPACE}", + {"X-Api-Key": api_key, "Content-Type": "application/json"}, + {"collection": collection}, + ) + col = created.get("collection") or {} + col_uid = col.get("uid") or col.get("id") + if not col_uid: + fail(f"collection create missing uid: {list(created.keys())}") + ok(f"Postman collection created: {col.get('name')} ({col_uid})") + + env_body = { + "environment": { + "name": "AM User Platform — Prod Port-Forward", + "values": [ + {"key": "base_url", "value": BASE, "type": "default", "enabled": True}, + { + "key": "keycloak_url", + "value": kc_url, + "type": "default", + "enabled": True, + }, + { + "key": "keycloak_realm", + "value": KC_REALM, + "type": "default", + "enabled": True, + }, + { + "key": "gateway_client_id", + "value": "am-gateway-client", + "type": "default", + "enabled": True, + }, + { + "key": "gateway_client_secret", + "value": gateway_secret, + "type": "secret", + "enabled": True, + }, + { + "key": "fin_agent_client_id", + "value": "am-fin-agent-service", + "type": "default", + "enabled": True, + }, + { + "key": "fin_agent_client_secret", + "value": "", + "type": "secret", + "enabled": True, + }, + { + "key": "test_email", + "value": "test.user@example.com", + "type": "default", + "enabled": True, + }, + { + "key": "test_password", + "value": "TestPass123!", + "type": "secret", + "enabled": True, + }, + {"key": "access_token", "value": "", "type": "secret", "enabled": True}, + { + "key": "service_access_token", + "value": "", + "type": "secret", + "enabled": True, + }, + {"key": "user_sub", "value": "", "type": "default", "enabled": True}, + {"key": "session_id", "value": "", "type": "default", "enabled": True}, + {"key": "message_id", "value": "", "type": "default", "enabled": True}, + {"key": "product_id", "value": "am_app", "type": "default", "enabled": True}, + { + "key": "agent_type", + "value": "fin_portfolio", + "type": "default", + "enabled": True, + }, + ], + } + } + env_created = http_json( + "POST", + f"https://api.getpostman.com/environments?workspace={WORKSPACE}", + {"X-Api-Key": api_key, "Content-Type": "application/json"}, + env_body, + ) + env = env_created.get("environment") or {} + env_uid = env.get("uid") or env.get("id") + if not env_uid: + fail("environment create missing uid") + ok(f"Postman environment created: {env.get('name')} ({env_uid})") + IDS_OUT.write_text( + json.dumps( + { + "collection_uid": col_uid, + "environment_uid": env_uid, + "workspace": WORKSPACE, + }, + indent=2, + ), + encoding="utf-8", + ) + ok(f"wrote {IDS_OUT.name}") + + # --- Smoke against local port-forward --- + results: list[tuple[str, bool, str]] = [] + + def check(name: str, cond: bool, detail: str = "") -> None: + results.append((name, cond, detail)) + print(f"[{'pass' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) + + def get(path: str, headers: dict | None = None) -> tuple[int, dict | str]: + req = urllib.request.Request(BASE + path, headers=headers or {}, method="GET") + try: + with urllib.request.urlopen(req, timeout=20) as resp: + raw = resp.read().decode() + try: + return resp.status, json.loads(raw) + except json.JSONDecodeError: + return resp.status, raw + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + try: + return e.code, json.loads(raw) + except json.JSONDecodeError: + return e.code, raw + + def post( + path: str, body: dict | None = None, headers: dict | None = None, form: dict | None = None + ) -> tuple[int, dict | str]: + hdrs = dict(headers or {}) + data = None + if form is not None: + data = urllib.parse.urlencode(form).encode() + hdrs["Content-Type"] = "application/x-www-form-urlencoded" + elif body is not None: + data = json.dumps(body).encode() + hdrs["Content-Type"] = "application/json" + req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method="POST") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode() + try: + return resp.status, json.loads(raw) + except json.JSONDecodeError: + return resp.status, raw + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + try: + return e.code, json.loads(raw) + except json.JSONDecodeError: + return e.code, raw + + def patch(path: str, body: dict, headers: dict) -> tuple[int, dict | str]: + data = json.dumps(body).encode() + hdrs = dict(headers) + hdrs["Content-Type"] = "application/json" + req = urllib.request.Request(BASE + path, data=data, headers=hdrs, method="PATCH") + try: + with urllib.request.urlopen(req, timeout=30) as resp: + raw = resp.read().decode() + try: + return resp.status, json.loads(raw) + except json.JSONDecodeError: + return resp.status, raw + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + try: + return e.code, json.loads(raw) + except json.JSONDecodeError: + return e.code, raw + + # Health + code, body = get("/health") + check("Health Check", code == 200 and isinstance(body, dict) and body.get("status") == "ok") + code, body = get("/ready") + check( + "Ready Check", + code == 200 + and isinstance(body, dict) + and body.get("database") is True, + f"database={body.get('database') if isinstance(body, dict) else '?'}", + ) + + # Service token + form = urllib.parse.urlencode( + { + "grant_type": "client_credentials", + "client_id": "am-gateway-client", + "client_secret": gateway_secret, + } + ).encode() + req = urllib.request.Request( + f"{kc_url}/realms/{KC_REALM}/protocol/openid-connect/token", + data=form, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + svc = json.loads(resp.read().decode()) + service_token = svc.get("access_token") + check("Client Credentials (gateway)", bool(service_token)) + except Exception as e: + service_token = None + check("Client Credentials (gateway)", False, type(e).__name__) + + # User token + form = urllib.parse.urlencode( + { + "grant_type": "password", + "client_id": "am-web-client", + "username": "test.user@example.com", + "password": "TestPass123!", + } + ).encode() + req = urllib.request.Request( + f"{kc_url}/realms/{KC_REALM}/protocol/openid-connect/token", + data=form, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + method="POST", + ) + user_sub = None + access_token = None + try: + with urllib.request.urlopen(req, timeout=30) as resp: + user = json.loads(resp.read().decode()) + access_token = user.get("access_token") + if access_token: + payload = access_token.split(".")[1] + payload += "=" * (-len(payload) % 4) + user_sub = json.loads(base64.urlsafe_b64decode(payload)).get("sub") + check("Password Login (am-web-client)", bool(access_token and user_sub)) + except urllib.error.HTTPError as e: + check("Password Login (am-web-client)", False, f"HTTP {e.code}") + except Exception as e: + check("Password Login (am-web-client)", False, type(e).__name__) + + # 401 without auth + code, _ = post( + "/internal/ai/sessions/00000000-0000-0000-0000-000000000001/messages", + body={ + "user_id": "x", + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [{"role": "user", "content": "hi"}], + }, + ) + check("Append Messages — no auth (expect 401)", code == 401, f"got {code}") + + session_id = str(uuid.uuid4()) + message_id = None + if service_token and user_sub: + code, body = post( + f"/internal/ai/sessions/{session_id}/messages", + body={ + "user_id": user_sub, + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [ + {"role": "user", "content": "hello from postman smoke"}, + { + "role": "assistant", + "content": "hi back", + "tokens_used": 12, + }, + ], + }, + headers={"Authorization": f"Bearer {service_token}"}, + ) + ok_append = code in (200, 201) and isinstance(body, dict) + check("Append Messages", ok_append, f"HTTP {code}") + if ok_append: + data = body.get("data") + if isinstance(data, list) and data: + message_id = data[0].get("id") + elif isinstance(data, dict): + message_id = (data.get("messages") or [{}])[-1].get("id") + + code, body = get( + f"/internal/ai/sessions/{session_id}/context?user_id={urllib.parse.quote(user_sub)}&limit=20", + headers={"Authorization": f"Bearer {service_token}"}, + ) + ctx_ok = code == 200 and isinstance(body, dict) + msgs = [] + if ctx_ok: + d = body.get("data") or {} + msgs = d.get("messages") or d.get("items") or [] + if isinstance(d, list): + msgs = d + check( + "Get Session Context", + ctx_ok and len(msgs) >= 2, + f"HTTP {code} msgs={len(msgs) if isinstance(msgs, list) else '?'}", + ) + + if access_token: + auth = {"Authorization": f"Bearer {access_token}"} + code, body = get( + "/v1/user-platform/ai/sessions?product_id=am_app&agent_type=fin_portfolio", + headers=auth, + ) + check("List Sessions", code == 200, f"HTTP {code}") + + code, body = get( + f"/v1/user-platform/ai/sessions/{session_id}", + headers=auth, + ) + check("Get Session", code == 200, f"HTTP {code}") + + code, body = patch( + f"/v1/user-platform/ai/sessions/{session_id}", + {"title": "Postman smoke"}, + headers=auth, + ) + check("Rename Session", code == 200, f"HTTP {code}") + + if message_id or True: + fb = { + "session_id": session_id, + "rating": -1, + "comment": "smoke feedback", + } + if message_id: + fb["message_id"] = message_id + code, body = post( + "/v1/user-platform/ai/feedback", + body=fb, + headers=auth, + ) + check("Submit Feedback", code in (200, 201), f"HTTP {code}") + + failed = [n for n, p, _ in results if not p] + print() + print(f"SMOKE: {len(results) - len(failed)}/{len(results)} passed") + if failed: + print("Failed:", ", ".join(failed)) + raise SystemExit(2) + print("ALL SMOKE CHECKS PASSED") + + +if __name__ == "__main__": + main() diff --git a/am-user-platform/scripts/provision-prod-secrets.ps1 b/am-user-platform/scripts/provision-prod-secrets.ps1 new file mode 100644 index 0000000..8fc90a0 --- /dev/null +++ b/am-user-platform/scripts/provision-prod-secrets.ps1 @@ -0,0 +1,201 @@ +<# +.SYNOPSIS + Register am-user-platform in Keycloak (verify) + Vault + Postgres for prod. + Prints only status lines — never secret values. +#> +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$ClientId = "am-user-platform-service" +$ClientUuid = "8e0fa780-ec7c-48e6-96a0-e07e3a980bac" +$Realm = "am-realm" +$DbName = "am_user_platform_prod" +$DbUser = "am_user_platform_user_prod" +$Kubeconfig = Join-Path $env:USERPROFILE ".asrax\kubeconfig.vps" +$CredsFile = Join-Path $env:USERPROFILE ".asrax\credentials.env" +$VaultServicePath = "secret/prod/services/am-user-platform" +$VaultPostgresPath = "apps/prod/infra/postgres" + +function Read-CredMap([string]$Path) { + $map = @{} + foreach ($line in Get-Content $Path) { + $t = $line.Trim() + if ($t -match '^\s*#' -or $t -eq '' -or $t -notmatch '=') { continue } + $k, $v = $t.Split('=', 2) + $map[$k.Trim()] = $v.Trim().Trim('"') + } + return $map +} + +function Write-JsonNoBom([string]$Path, [hashtable]$Data) { + $json = $Data | ConvertTo-Json -Compress + $utf8 = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($Path, $json, $utf8) +} + +function Status([string]$msg) { Write-Host "[ok] $msg" -ForegroundColor Green } +function Fail([string]$msg) { Write-Host "[fail] $msg" -ForegroundColor Red; exit 1 } + +if (-not (Test-Path $CredsFile)) { Fail "Missing $CredsFile" } +if (-not (Test-Path $Kubeconfig)) { Fail "Missing $Kubeconfig" } + +$creds = Read-CredMap $CredsFile +foreach ($req in @('VAULT_ADDR', 'VAULT_TOKEN', 'KEYCLOAK_URL', 'KEYCLOAK_ADMIN', 'KEYCLOAK_ADMIN_PASSWORD')) { + if (-not $creds.ContainsKey($req) -or [string]::IsNullOrWhiteSpace($creds[$req])) { + Fail "credentials.env missing $req" + } +} +$env:VAULT_ADDR = $creds['VAULT_ADDR'] +$env:VAULT_TOKEN = $creds['VAULT_TOKEN'] + +# ── Keycloak: admin token + client secret ──────────────────────────────────── +$kcBase = $creds['KEYCLOAK_URL'].TrimEnd('/') +$tokenBody = @{ + grant_type = 'password' + client_id = 'admin-cli' + username = $creds['KEYCLOAK_ADMIN'] + password = $creds['KEYCLOAK_ADMIN_PASSWORD'] +} +$tokenResp = Invoke-RestMethod -Method Post -Uri "$kcBase/realms/master/protocol/openid-connect/token" -Body $tokenBody -ContentType 'application/x-www-form-urlencoded' +$headers = @{ Authorization = "Bearer $($tokenResp.access_token)" } + +# Align client flags with am-subscription-service +$client = Invoke-RestMethod -Method Get -Uri "$kcBase/admin/realms/$Realm/clients/$ClientUuid" -Headers $headers +$client.directAccessGrantsEnabled = $false +$client.standardFlowEnabled = $false +$client.serviceAccountsEnabled = $true +$client.publicClient = $false +if ($null -ne ($client.PSObject.Properties['description'])) { + $client.description = 'Internal service client for user platform (AI sessions/feedback)' +} else { + $client | Add-Member -NotePropertyName description -NotePropertyValue 'Internal service client for user platform (AI sessions/feedback)' -Force +} +Invoke-RestMethod -Method Put -Uri "$kcBase/admin/realms/$Realm/clients/$ClientUuid" -Headers $headers -ContentType 'application/json' -Body ($client | ConvertTo-Json -Depth 20) | Out-Null +Status "Keycloak client $ClientId updated (service-account, no direct grant)" + +$secretResp = Invoke-RestMethod -Method Get -Uri "$kcBase/admin/realms/$Realm/clients/$ClientUuid/client-secret" -Headers $headers +$clientSecret = $secretResp.value +if ([string]::IsNullOrWhiteSpace($clientSecret)) { Fail 'Empty Keycloak client secret' } +Status 'Keycloak client secret retrieved' + +# ── Generate DB password + optional SERVICE_TOKEN ──────────────────────────── +$rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() +function New-Hex([int]$bytes) { + $buf = New-Object byte[] $bytes + $rng.GetBytes($buf) + return ([BitConverter]::ToString($buf) -replace '-', '').ToLowerInvariant() +} +$dbPassword = New-Hex 24 +$serviceToken = New-Hex 32 + +# ── Vault: service secret ──────────────────────────────────────────────────── +$tmp = Join-Path $env:TEMP "am-user-platform-vault.json" +Write-JsonNoBom $tmp @{ + AM_USER_PLATFORM_CLIENT_ID = $ClientId + AM_USER_PLATFORM_CLIENT_SECRET = $clientSecret + SERVICE_TOKEN = $serviceToken +} +try { + & vault kv put $VaultServicePath ("@" + $tmp) + if ($LASTEXITCODE -ne 0) { Fail "vault kv put $VaultServicePath failed" } + Status "Vault wrote $VaultServicePath (keys only: CLIENT_ID, CLIENT_SECRET, SERVICE_TOKEN)" +} +finally { + Remove-Item -Force -ErrorAction SilentlyContinue $tmp +} + +# ── Vault: patch postgres keys (merge) ─────────────────────────────────────── +$tmpPg = Join-Path $env:TEMP "am-user-platform-pg-patch.json" +Write-JsonNoBom $tmpPg @{ + AM_USER_PLATFORM_DB_NAME = $DbName + AM_USER_PLATFORM_DB_USER = $DbUser + AM_USER_PLATFORM_DB_PASSWORD = $dbPassword +} +try { + & vault kv patch $VaultPostgresPath ("@" + $tmpPg) + if ($LASTEXITCODE -ne 0) { + # Older vault may lack patch — merge manually + $existing = vault kv get -format=json $VaultPostgresPath | ConvertFrom-Json + $merged = @{} + foreach ($p in $existing.data.data.PSObject.Properties) { $merged[$p.Name] = [string]$p.Value } + $merged['AM_USER_PLATFORM_DB_NAME'] = $DbName + $merged['AM_USER_PLATFORM_DB_USER'] = $DbUser + $merged['AM_USER_PLATFORM_DB_PASSWORD'] = $dbPassword + Write-JsonNoBom $tmpPg $merged + & vault kv put $VaultPostgresPath ("@" + $tmpPg) + if ($LASTEXITCODE -ne 0) { Fail "vault put/patch postgres failed" } + } + Status "Vault patched $VaultPostgresPath with AM_USER_PLATFORM_DB keys" +} +finally { + Remove-Item -Force -ErrorAction SilentlyContinue $tmpPg +} + +# Verify key presence only +$svcKeys = (vault kv get -format=json $VaultServicePath | ConvertFrom-Json).data.data.PSObject.Properties.Name +$pgKeys = (vault kv get -format=json $VaultPostgresPath | ConvertFrom-Json).data.data.PSObject.Properties.Name +foreach ($k in @('AM_USER_PLATFORM_CLIENT_ID', 'AM_USER_PLATFORM_CLIENT_SECRET', 'SERVICE_TOKEN')) { + if ($svcKeys -notcontains $k) { Fail "Missing service key $k" } +} +foreach ($k in @('AM_USER_PLATFORM_DB_NAME', 'AM_USER_PLATFORM_DB_USER', 'AM_USER_PLATFORM_DB_PASSWORD', 'host', 'port')) { + if ($pgKeys -notcontains $k) { Fail "Missing postgres key $k" } +} +Status 'Vault verification: required keys present' + +# ── Postgres: create role + database via kubectl (stdin; no kubectl cp) ─────── +$pgAdminPass = (vault kv get -format=json $VaultPostgresPath | ConvertFrom-Json).data.data.password +$pgAdminUser = (vault kv get -format=json $VaultPostgresPath | ConvertFrom-Json).data.data.username +if ([string]::IsNullOrWhiteSpace($pgAdminPass)) { Fail 'Vault postgres admin password missing' } + +function Invoke-Psql([string]$Database, [string]$Sql) { + $psi = New-Object System.Diagnostics.ProcessStartInfo + $psi.FileName = "kubectl" + $psi.Arguments = "--kubeconfig `"$Kubeconfig`" -n infra exec -i postgresql-0 -c postgresql -- env PGPASSWORD=$pgAdminPass psql -U $pgAdminUser -d $Database -v ON_ERROR_STOP=1 -t -A" + $psi.RedirectStandardInput = $true + $psi.RedirectStandardOutput = $true + $psi.RedirectStandardError = $true + $psi.UseShellExecute = $false + $p = [System.Diagnostics.Process]::Start($psi) + $p.StandardInput.Write($Sql) + $p.StandardInput.Close() + $stdout = $p.StandardOutput.ReadToEnd() + $stderr = $p.StandardError.ReadToEnd() + $p.WaitForExit() + if ($p.ExitCode -ne 0) { + Fail "psql failed on $Database : $stderr $stdout" + } + return $stdout.Trim() +} + +$roleSql = @" +DO `$`$ +BEGIN + IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '$DbUser') THEN + CREATE ROLE $DbUser WITH LOGIN PASSWORD '$dbPassword'; + ELSE + ALTER ROLE $DbUser WITH PASSWORD '$dbPassword'; + END IF; +END +`$`$; +"@ +Invoke-Psql "postgres" $roleSql | Out-Null +Status "Postgres role $DbUser ensured" + +$dbExists = Invoke-Psql "postgres" "SELECT count(*) FROM pg_database WHERE datname = '$DbName';" +if ($dbExists -eq '0') { + Invoke-Psql "postgres" "CREATE DATABASE $DbName OWNER $DbUser;" | Out-Null + Status "Postgres database $DbName created" +} else { + Status "Postgres database $DbName already exists" +} + +Invoke-Psql $DbName "GRANT ALL ON SCHEMA public TO $DbUser; ALTER DATABASE $DbName OWNER TO $DbUser;" | Out-Null +Status "Postgres grants applied" + +Write-Host "" +Write-Host "DONE - am-user-platform registered for prod" -ForegroundColor Cyan +Write-Host " Keycloak : $Realm / $ClientId" +Write-Host " Vault : $VaultServicePath" +Write-Host " Vault : $VaultPostgresPath with AM_USER_PLATFORM_DB keys" +Write-Host " Postgres : db=$DbName user=$DbUser" +Write-Host " Helm injector paths: secret/data/prod/services/am-user-platform and apps/data/prod/infra/postgres" diff --git a/am-user-platform/scripts/rebuild_postman_payload.py b/am-user-platform/scripts/rebuild_postman_payload.py new file mode 100644 index 0000000..ebfa6fe --- /dev/null +++ b/am-user-platform/scripts/rebuild_postman_payload.py @@ -0,0 +1,22 @@ +import json +import pathlib + +src = pathlib.Path(__file__).resolve().parents[1] / "postman" / "AM-User-Platform.postman_collection.json" +out = pathlib.Path(__file__).resolve().parents[1] / "postman" / "_upload_payload.json" +d = json.loads(src.read_text(encoding="utf-8")) +d["info"].pop("_postman_id", None) +d["info"]["schema"] = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" +vars_ = d.setdefault("variable", []) +if not any(v.get("key") == "identity_base_url" for v in vars_): + vars_.insert(1, {"key": "identity_base_url", "value": "https://am.asrax.in"}) +for v in vars_: + if v.get("key") == "keycloak_url": + v["value"] = "https://auth.asrax.in/auth" +payload = { + "info": d["info"], + "item": d["item"], + "event": d.get("event", []), + "variable": vars_, +} +out.write_text(json.dumps(payload), encoding="utf-8") +print("ok", out.stat().st_size) diff --git a/am-user-platform/scripts/smoke_prod_portforward.py b/am-user-platform/scripts/smoke_prod_portforward.py new file mode 100644 index 0000000..002d23f --- /dev/null +++ b/am-user-platform/scripts/smoke_prod_portforward.py @@ -0,0 +1,243 @@ +"""Smoke-test am-user-platform against http://127.0.0.1:8115 (port-forward). + +Env vars (required): + GATEWAY_CLIENT_SECRET + TEST_EMAIL + TEST_PASSWORD + +Optional: + KEYCLOAK_URL (default http://auth.munish.org/auth) + KEYCLOAK_REALM (default am-realm) +""" + +from __future__ import annotations + +import base64 +import json +import os +import urllib.error +import urllib.parse +import urllib.request +import uuid + +BASE = os.environ.get("BASE_URL", "http://127.0.0.1:8115").rstrip("/") +KEYCLOAK = os.environ.get("KEYCLOAK_URL", "https://auth.munish.org/auth").rstrip("/") +REALM = os.environ.get("KEYCLOAK_REALM", "am-realm") +GW_SECRET = os.environ.get("GATEWAY_CLIENT_SECRET", "") +TEST_EMAIL = os.environ.get("TEST_EMAIL", "test.user@example.com") +TEST_PASSWORD = os.environ.get("TEST_PASSWORD", "") + + +def check(name: str, cond: bool, detail: str = "") -> bool: + print(f"[{'pass' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) + return cond + + +def req(method: str, url: str, headers: dict | None = None, data: bytes | None = None): + hdrs = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ), + "Accept": "application/json", + } + if headers: + hdrs.update(headers) + r = urllib.request.Request(url, data=data, headers=hdrs, method=method) + try: + with urllib.request.urlopen(r, timeout=30) as resp: + raw = resp.read().decode() + try: + return resp.status, json.loads(raw) + except json.JSONDecodeError: + return resp.status, raw + except urllib.error.HTTPError as e: + raw = e.read().decode(errors="replace") + try: + return e.code, json.loads(raw) + except json.JSONDecodeError: + return e.code, raw + except Exception as e: + return 0, {"error": type(e).__name__, "msg": str(e)[:120]} + + +def token_form(fields: dict) -> str: + data = urllib.parse.urlencode(fields).encode() + code, body = req( + "POST", + f"{KEYCLOAK}/realms/{REALM}/protocol/openid-connect/token", + {"Content-Type": "application/x-www-form-urlencoded"}, + data, + ) + if code != 200 or not isinstance(body, dict): + raise RuntimeError(f"token HTTP {code}") + return body["access_token"] + + +def main() -> None: + if not GW_SECRET or not TEST_PASSWORD: + raise SystemExit("FAIL: set GATEWAY_CLIENT_SECRET and TEST_PASSWORD") + + passed = 0 + total = 0 + + def run(name: str, cond: bool, detail: str = "") -> None: + nonlocal passed, total + total += 1 + if check(name, cond, detail): + passed += 1 + + code, body = req("GET", f"{BASE}/health") + run("Health", code == 200 and isinstance(body, dict) and body.get("status") == "ok") + + code, body = req("GET", f"{BASE}/ready") + run( + "Ready database=true", + code == 200 and isinstance(body, dict) and body.get("database") is True, + str(body.get("database") if isinstance(body, dict) else body)[:80], + ) + + try: + svc = token_form( + { + "grant_type": "client_credentials", + "client_id": "am-gateway-client", + "client_secret": GW_SECRET, + } + ) + run("Gateway client credentials", bool(svc)) + except Exception as e: + svc = "" + run("Gateway client credentials", False, type(e).__name__) + + try: + # am-web-client has directAccessGrants disabled in prod — use am-identity. + code, body = req( + "POST", + "https://am.asrax.in/identity/auth/login", + {"Content-Type": "application/json"}, + json.dumps({"username": TEST_EMAIL, "password": TEST_PASSWORD}).encode(), + ) + user = body.get("access_token") if isinstance(body, dict) else None + user_sub = None + if user: + payload = user.split(".")[1] + payload += "=" * (-len(payload) % 4) + user_sub = json.loads(base64.urlsafe_b64decode(payload))["sub"] + run("Identity password login", bool(user and user_sub), f"HTTP {code}") + except Exception as e: + user = "" + user_sub = "" + run("Identity password login", False, type(e).__name__) + + session_id = str(uuid.uuid4()) + code, _ = req( + "POST", + f"{BASE}/internal/ai/sessions/{session_id}/messages", + {"Content-Type": "application/json"}, + json.dumps( + { + "user_id": user_sub or "x", + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [{"role": "user", "content": "noauth"}], + } + ).encode(), + ) + run("Internal append without auth -> 401", code == 401, f"got {code}") + + message_id = None + if svc and user_sub: + code, body = req( + "POST", + f"{BASE}/internal/ai/sessions/{session_id}/messages", + { + "Content-Type": "application/json", + "Authorization": f"Bearer {svc}", + }, + json.dumps( + { + "user_id": user_sub, + "product_id": "am_app", + "agent_type": "fin_portfolio", + "channel": "user_app", + "messages": [ + {"role": "user", "content": "Show my portfolio summary"}, + { + "role": "assistant", + "content": "Here is your portfolio overview.", + "tokens_used": 3842, + "tools_used": ["get_portfolio_summary"], + }, + ], + } + ).encode(), + ) + run("Internal append messages", code in (200, 201), f"HTTP {code} {str(body)[:160]}") + if isinstance(body, dict): + data = body.get("data") + if isinstance(data, list) and data: + message_id = data[-1].get("id") + elif isinstance(data, dict) and data.get("messages"): + message_id = data["messages"][-1].get("id") + + q = urllib.parse.urlencode({"user_id": user_sub, "limit": "20"}) + code, body = req( + "GET", + f"{BASE}/internal/ai/sessions/{session_id}/context?{q}", + {"Authorization": f"Bearer {svc}"}, + ) + n = 0 + if isinstance(body, dict): + d = body.get("data") or {} + if isinstance(d, dict): + n = len(d.get("messages") or []) + elif isinstance(d, list): + n = len(d) + run("Internal get context", code == 200 and n >= 2, f"HTTP {code} msgs={n} {str(body)[:120]}") + + if user: + auth = {"Authorization": f"Bearer {user}"} + code, _ = req( + "GET", + f"{BASE}/v1/user-platform/ai/sessions?product_id=am_app&agent_type=fin_portfolio&limit=50", + auth, + ) + run("List sessions", code == 200, f"HTTP {code}") + + code, _ = req("GET", f"{BASE}/v1/user-platform/ai/sessions/{session_id}", auth) + run("Get session", code == 200, f"HTTP {code}") + + code, _ = req( + "PATCH", + f"{BASE}/v1/user-platform/ai/sessions/{session_id}", + {**auth, "Content-Type": "application/json"}, + json.dumps({"title": "Postman smoke"}).encode(), + ) + run("Rename session", code == 200, f"HTTP {code}") + + fb = { + "session_id": session_id, + "agent_type": "fin_portfolio", + "rating": "down", + "comment": "smoke", + } + if message_id: + fb["message_id"] = message_id + code, _ = req( + "POST", + f"{BASE}/v1/user-platform/ai/feedback", + {**auth, "Content-Type": "application/json"}, + json.dumps(fb).encode(), + ) + run("Submit feedback", code in (200, 201), f"HTTP {code}") + + print() + print(f"SMOKE: {passed}/{total} passed") + if passed != total: + raise SystemExit(2) + print("ALL CHECKS PASSED") + + +if __name__ == "__main__": + main() diff --git a/am-user-platform/scripts/upload_postman_collection.py b/am-user-platform/scripts/upload_postman_collection.py new file mode 100644 index 0000000..c7db85b --- /dev/null +++ b/am-user-platform/scripts/upload_postman_collection.py @@ -0,0 +1,64 @@ +"""Upload local collection JSON to Postman workspace. Needs POSTMAN_API_KEY in ~/.asrax/credentials.env.""" + +from __future__ import annotations + +import json +import pathlib +import urllib.error +import urllib.request + +WORKSPACE = "c1ed9ba4-e485-4377-8374-7685abf4247d" +ROOT = pathlib.Path(__file__).resolve().parents[1] / "postman" +PAYLOAD = ROOT / "_upload_payload.json" +IDS = ROOT / "_postman_ids.json" +ENV_UID = "56761657-3f6f40c7-80f0-4a72-9b6d-344dfeb183c3" + + +def main() -> None: + creds: dict[str, str] = {} + for line in (pathlib.Path.home() / ".asrax" / "credentials.env").read_text( + encoding="utf-8" + ).splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + creds[k.strip()] = v.strip().strip('"') + key = creds.get("POSTMAN_API_KEY") or creds.get("POSTMAN_KEY") + if not key: + raise SystemExit("FAIL: POSTMAN_API_KEY missing") + + col = json.loads(PAYLOAD.read_text(encoding="utf-8")) + body = json.dumps({"collection": col}).encode() + req = urllib.request.Request( + f"https://api.getpostman.com/collections?workspace={WORKSPACE}", + data=body, + headers={"X-Api-Key": key, "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + data = json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + print("FAIL", e.code, e.read().decode(errors="replace")[:400]) + raise SystemExit(1) from e + + c = data.get("collection") or {} + uid = c.get("uid") or c.get("id") + print("OK collection", c.get("name"), uid) + IDS.write_text( + json.dumps( + { + "collection_uid": uid, + "environment_uid": ENV_UID, + "workspace": WORKSPACE, + }, + indent=2, + ), + encoding="utf-8", + ) + print("OK wrote", IDS.name) + + +if __name__ == "__main__": + main() diff --git a/am-user-platform/tests/__init__.py b/am-user-platform/tests/__init__.py new file mode 100644 index 0000000..ee28ffd --- /dev/null +++ b/am-user-platform/tests/__init__.py @@ -0,0 +1 @@ +"""Pytest package for am-user-platform.""" diff --git a/am-user-platform/tests/conftest.py b/am-user-platform/tests/conftest.py new file mode 100644 index 0000000..2fb5095 --- /dev/null +++ b/am-user-platform/tests/conftest.py @@ -0,0 +1,72 @@ +"""Shared fixtures for am-user-platform tests.""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest +from fastapi.testclient import TestClient + +from am_platform_security import AuthContext + +from am_user_platform.core.database import init_db, ping_db +from am_user_platform.main import app +from am_user_platform.modules.ai.api.internal_router import ServiceAuth +from am_user_platform.modules.ai.api.session_router import UserAuth + + +def service_context() -> AuthContext: + return AuthContext( + subject="am-fin-agent", + client_id="am-fin-agent", + token_type="service", + roles=["service"], + access_token="test-service-token", + ) + + +def user_context(user_id: str) -> AuthContext: + return AuthContext( + subject=user_id, + client_id="am-app", + token_type="user", + roles=["user"], + access_token="test-user-token", + ) + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture +def api_user_id() -> str: + return "api-test-user" + + +@pytest.fixture(autouse=True) +def auth_overrides(api_user_id: str): + app.dependency_overrides[ServiceAuth] = service_context + app.dependency_overrides[UserAuth] = lambda: user_context(api_user_id) + yield + app.dependency_overrides.clear() + + +@pytest.fixture(scope="session") +def postgres_ready() -> bool: + if not asyncio.run(ping_db()): + return False + asyncio.run(init_db()) + return True + + +@pytest.fixture +def user_id() -> str: + return f"test-user-{uuid.uuid4()}" + + +@pytest.fixture +def other_user_id() -> str: + return f"test-user-{uuid.uuid4()}" diff --git a/am-user-platform/tests/modules/__init__.py b/am-user-platform/tests/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/am-user-platform/tests/modules/ai/__init__.py b/am-user-platform/tests/modules/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/am-user-platform/tests/modules/ai/test_api.py b/am-user-platform/tests/modules/ai/test_api.py new file mode 100644 index 0000000..6a89bfb --- /dev/null +++ b/am-user-platform/tests/modules/ai/test_api.py @@ -0,0 +1,189 @@ +"""HTTP API tests for ai routers.""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest +from fastapi.testclient import TestClient + +from am_platform_security import AuthContext + +from am_user_platform.core.database import init_db, ping_db +from am_user_platform.main import app +from am_user_platform.modules.ai.api.internal_router import ServiceAuth +from am_user_platform.modules.ai.api.session_router import UserAuth + + +def _service_context() -> AuthContext: + return AuthContext( + subject="am-fin-agent", + client_id="am-fin-agent", + token_type="service", + roles=["service"], + access_token="test-service-token", + ) + + +def _user_context(user_id: str) -> AuthContext: + return AuthContext( + subject=user_id, + client_id="am-app", + token_type="user", + roles=["user"], + access_token="test-user-token", + ) + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture(autouse=True) +def auth_overrides(): + app.dependency_overrides[ServiceAuth] = _service_context + app.dependency_overrides[UserAuth] = lambda: _user_context("api-test-user") + yield + app.dependency_overrides.clear() + + +def test_internal_append_requires_auth_when_override_cleared(client: TestClient) -> None: + app.dependency_overrides.clear() + session_id = uuid.uuid4() + response = client.post( + f"/internal/ai/sessions/{session_id}/messages", + json={ + "user_id": "user-1", + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [{"role": "user", "content": "Hi"}], + }, + ) + assert response.status_code == 401 + app.dependency_overrides[ServiceAuth] = _service_context + app.dependency_overrides[UserAuth] = lambda: _user_context("api-test-user") + + +def test_internal_routes_registered(client: TestClient) -> None: + assert any(r.path.startswith("/internal/ai") for r in app.routes) + + +def test_user_routes_registered(client: TestClient) -> None: + paths = {getattr(r, "path", "") for r in app.routes} + assert "/v1/user-platform/ai/sessions" in paths + assert "/v1/user-platform/ai/feedback" in paths + + +@pytest.fixture(scope="module") +def postgres_ready() -> bool: + if not asyncio.run(ping_db()): + return False + asyncio.run(init_db()) + return True + + +def test_internal_append_and_context_integration( + client: TestClient, postgres_ready: bool +) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + user_id = f"api-user-{uuid.uuid4()}" + session_id = uuid.uuid4() + append = client.post( + f"/internal/ai/sessions/{session_id}/messages", + json={ + "user_id": user_id, + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [ + {"role": "user", "content": "Portfolio summary?"}, + { + "role": "assistant", + "content": "Here you go", + "tokens_used": 900, + }, + ], + }, + ) + assert append.status_code == 201 + assert len(append.json()["data"]) == 2 + + context = client.get( + f"/internal/ai/sessions/{session_id}/context", + params={"user_id": user_id, "limit": 10}, + ) + assert context.status_code == 200 + assert len(context.json()["data"]["messages"]) == 2 + + purge = client.delete(f"/internal/ai/users/{user_id}/data") + assert purge.status_code == 200 + assert purge.json()["data"]["sessions_deleted"] >= 1 + + +def test_user_session_tenancy(client: TestClient, postgres_ready: bool) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + owner_id = f"owner-{uuid.uuid4()}" + other_id = f"other-{uuid.uuid4()}" + session_id = uuid.uuid4() + + app.dependency_overrides[ServiceAuth] = _service_context + created = client.post( + f"/internal/ai/sessions/{session_id}/messages", + json={ + "user_id": owner_id, + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [{"role": "user", "content": "Hello"}], + }, + ) + assert created.status_code == 201 + + app.dependency_overrides[UserAuth] = lambda: _user_context(other_id) + denied = client.get(f"/v1/user-platform/ai/sessions/{session_id}") + assert denied.status_code == 404 + + app.dependency_overrides[ServiceAuth] = _service_context + client.delete(f"/internal/ai/users/{owner_id}/data") + + +def test_user_feedback_integration(client: TestClient, postgres_ready: bool) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + user_id = f"fb-user-{uuid.uuid4()}" + session_id = uuid.uuid4() + + app.dependency_overrides[ServiceAuth] = _service_context + client.post( + f"/internal/ai/sessions/{session_id}/messages", + json={ + "user_id": user_id, + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [ + {"role": "user", "content": "Q"}, + {"role": "assistant", "content": "A"}, + ], + }, + ) + + app.dependency_overrides[UserAuth] = lambda: _user_context(user_id) + feedback = client.post( + "/v1/user-platform/ai/feedback", + json={ + "session_id": str(session_id), + "agent_type": "fin_portfolio", + "rating": "down", + "comment": "Not helpful", + }, + ) + assert feedback.status_code == 201 + assert feedback.json()["data"]["rating"] == "down" + + app.dependency_overrides[ServiceAuth] = _service_context + client.delete(f"/internal/ai/users/{user_id}/data") diff --git a/am-user-platform/tests/modules/ai/test_feedback.py b/am-user-platform/tests/modules/ai/test_feedback.py new file mode 100644 index 0000000..9fa9254 --- /dev/null +++ b/am-user-platform/tests/modules/ai/test_feedback.py @@ -0,0 +1,121 @@ +"""C0.50 — create feedback linked to session/message.""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest + +from am_user_platform.core.database import get_session_factory +from am_user_platform.modules.ai.models.db import MessageRole +from am_user_platform.modules.ai.schemas.feedback import FeedbackCreate +from am_user_platform.modules.ai.schemas.message import ( + AppendMessagesRequest, + MessageAppendItem, +) +from am_user_platform.modules.ai.schemas.session import SessionCreate +from am_user_platform.modules.ai.services.feedback_service import FeedbackService +from am_user_platform.modules.ai.services.message_service import MessageService +from am_user_platform.modules.ai.services.session_service import SessionService +from am_user_platform.main import app +from am_user_platform.modules.ai.api.session_router import UserAuth +from tests.conftest import user_context + + +def _run(coro): + return asyncio.run(coro) + + +def test_feedback_linked_to_session_and_message( + postgres_ready: bool, user_id: str +) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + async def _test() -> None: + factory = get_session_factory() + async with factory() as db: + sessions = SessionService(db) + messages = MessageService(db) + feedback = FeedbackService(db) + created = await sessions.create_session( + user_id, + SessionCreate( + product_id="am_app", + agent_type="fin_portfolio", + ), + ) + rows = await messages.append_messages( + created.id, + AppendMessagesRequest( + user_id=user_id, + product_id="am_app", + agent_type="fin_portfolio", + messages=[ + MessageAppendItem(role=MessageRole.user, content="Q"), + MessageAppendItem(role=MessageRole.assistant, content="A"), + ], + ), + ) + assistant_id = rows[-1].id + row = await feedback.create_feedback( + user_id, + FeedbackCreate( + session_id=created.id, + message_id=assistant_id, + agent_type="fin_portfolio", + rating="up", + comment="helpful", + ), + ) + assert row.session_id == created.id + assert row.message_id == assistant_id + assert row.rating == "up" + + await db.commit() + await sessions.purge_user_data(user_id) + await db.commit() + + _run(_test()) + + +def test_user_feedback_api(client, postgres_ready: bool) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + user_id = f"fb-user-{uuid.uuid4()}" + session_id = uuid.uuid4() + append = client.post( + f"/internal/ai/sessions/{session_id}/messages", + json={ + "user_id": user_id, + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [ + {"role": "user", "content": "Q"}, + {"role": "assistant", "content": "A"}, + ], + }, + ) + assert append.status_code == 201 + message_id = append.json()["data"][-1]["id"] + + app.dependency_overrides[UserAuth] = lambda: user_context(user_id) + feedback = client.post( + "/v1/user-platform/ai/feedback", + json={ + "session_id": str(session_id), + "message_id": message_id, + "agent_type": "fin_portfolio", + "rating": "down", + "comment": "Not helpful", + }, + ) + assert feedback.status_code == 201 + data = feedback.json()["data"] + assert data["rating"] == "down" + assert data["session_id"] == str(session_id) + assert data["message_id"] == message_id + + client.delete(f"/internal/ai/users/{user_id}/data") diff --git a/am-user-platform/tests/modules/ai/test_helpers.py b/am-user-platform/tests/modules/ai/test_helpers.py new file mode 100644 index 0000000..ae5f67a --- /dev/null +++ b/am-user-platform/tests/modules/ai/test_helpers.py @@ -0,0 +1,18 @@ +"""Helper unit tests.""" + +from am_user_platform.modules.ai.services.helpers import title_from_first_message + + +def test_title_from_short_message() -> None: + assert title_from_first_message("Show my portfolio") == "Show my portfolio" + + +def test_title_truncates_long_message() -> None: + long_text = "a" * 60 + title = title_from_first_message(long_text) + assert len(title) <= 50 + assert title.endswith("…") + + +def test_title_default_for_blank() -> None: + assert title_from_first_message(" ") == "New chat" diff --git a/am-user-platform/tests/modules/ai/test_internal_auth.py b/am-user-platform/tests/modules/ai/test_internal_auth.py new file mode 100644 index 0000000..28606ef --- /dev/null +++ b/am-user-platform/tests/modules/ai/test_internal_auth.py @@ -0,0 +1,47 @@ +"""C0.52 — 401 without service token on internal routes.""" + +from __future__ import annotations + +import uuid + +from am_user_platform.main import app +from am_user_platform.modules.ai.api.internal_router import ServiceAuth +from am_user_platform.modules.ai.api.session_router import UserAuth +from tests.conftest import service_context, user_context + + +def test_internal_append_requires_auth(client) -> None: + app.dependency_overrides.clear() + session_id = uuid.uuid4() + response = client.post( + f"/internal/ai/sessions/{session_id}/messages", + json={ + "user_id": "user-1", + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [{"role": "user", "content": "Hi"}], + }, + ) + assert response.status_code == 401 + app.dependency_overrides[ServiceAuth] = service_context + app.dependency_overrides[UserAuth] = lambda: user_context("api-test-user") + + +def test_internal_context_requires_auth(client) -> None: + app.dependency_overrides.clear() + session_id = uuid.uuid4() + response = client.get( + f"/internal/ai/sessions/{session_id}/context", + params={"user_id": "user-1", "limit": 5}, + ) + assert response.status_code == 401 + app.dependency_overrides[ServiceAuth] = service_context + app.dependency_overrides[UserAuth] = lambda: user_context("api-test-user") + + +def test_internal_purge_requires_auth(client) -> None: + app.dependency_overrides.clear() + response = client.delete("/internal/ai/users/user-1/data") + assert response.status_code == 401 + app.dependency_overrides[ServiceAuth] = service_context + app.dependency_overrides[UserAuth] = lambda: user_context("api-test-user") diff --git a/am-user-platform/tests/modules/ai/test_messages.py b/am-user-platform/tests/modules/ai/test_messages.py new file mode 100644 index 0000000..f9d8bdb --- /dev/null +++ b/am-user-platform/tests/modules/ai/test_messages.py @@ -0,0 +1,106 @@ +"""C0.49 — append, get context, tokens_used stored.""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest + +from am_user_platform.core.database import get_session_factory +from am_user_platform.modules.ai.models.db import MessageRole +from am_user_platform.modules.ai.schemas.message import ( + AppendMessagesRequest, + MessageAppendItem, +) +from am_user_platform.modules.ai.schemas.session import SessionCreate +from am_user_platform.modules.ai.services.message_service import MessageService +from am_user_platform.modules.ai.services.session_service import SessionService + + +def _run(coro): + return asyncio.run(coro) + + +def test_append_get_context_tokens_used(postgres_ready: bool, user_id: str) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + async def _test() -> None: + factory = get_session_factory() + async with factory() as db: + sessions = SessionService(db) + messages = MessageService(db) + session_id = uuid.uuid4() + await sessions.create_session( + user_id, + SessionCreate( + id=session_id, + product_id="am_app", + agent_type="fin_portfolio", + ), + ) + await messages.append_messages( + session_id, + AppendMessagesRequest( + user_id=user_id, + product_id="am_app", + agent_type="fin_portfolio", + messages=[ + MessageAppendItem(role=MessageRole.user, content="Portfolio?"), + MessageAppendItem( + role=MessageRole.assistant, + content="Summary", + tokens_used=1200, + tools_used=["get_portfolio_summary"], + ), + ], + ), + ) + ctx = await messages.get_context(session_id, user_id, limit=5) + assert len(ctx.messages) == 2 + assert ctx.messages[0].role == MessageRole.user + assert ctx.messages[1].tokens_used == 1200 + assert ctx.messages[1].tools_used == ["get_portfolio_summary"] + + await db.commit() + await sessions.purge_user_data(user_id) + await db.commit() + + _run(_test()) + + +def test_internal_append_and_context_api(client, postgres_ready: bool) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + user_id = f"api-user-{uuid.uuid4()}" + session_id = uuid.uuid4() + append = client.post( + f"/internal/ai/sessions/{session_id}/messages", + json={ + "user_id": user_id, + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [ + {"role": "user", "content": "Portfolio summary?"}, + { + "role": "assistant", + "content": "Here you go", + "tokens_used": 900, + }, + ], + }, + ) + assert append.status_code == 201 + assert len(append.json()["data"]) == 2 + assert append.json()["data"][1]["tokens_used"] == 900 + + context = client.get( + f"/internal/ai/sessions/{session_id}/context", + params={"user_id": user_id, "limit": 10}, + ) + assert context.status_code == 200 + assert len(context.json()["data"]["messages"]) == 2 + + client.delete(f"/internal/ai/users/{user_id}/data") diff --git a/am-user-platform/tests/modules/ai/test_models.py b/am-user-platform/tests/modules/ai/test_models.py new file mode 100644 index 0000000..1e14f68 --- /dev/null +++ b/am-user-platform/tests/modules/ai/test_models.py @@ -0,0 +1,102 @@ +"""Model metadata tests — no Postgres required.""" + +from __future__ import annotations + +import uuid + +from am_user_platform.modules.ai.models.db import ( + AI_SCHEMA, + AiFeedback, + AiMessage, + AiSession, + MessageRole, +) + + +def _column_names(model) -> set[str]: + return {column.name for column in model.__table__.columns} + + +def test_ai_schema_constant() -> None: + assert AI_SCHEMA == "ai" + + +def test_session_table_in_ai_schema() -> None: + assert AiSession.__table__.schema == "ai" + assert AiSession.__tablename__ == "ai_sessions" + + +def test_session_columns() -> None: + cols = _column_names(AiSession) + assert { + "id", + "user_id", + "product_id", + "agent_type", + "channel", + "title", + "org_id", + "created_at", + "updated_at", + "deleted_at", + }.issubset(cols) + + +def test_message_table_and_tokens_used() -> None: + assert AiMessage.__table__.schema == "ai" + assert AiMessage.__tablename__ == "ai_messages" + cols = _column_names(AiMessage) + assert "tokens_used" in cols + assert "session_id" in cols + assert "widget_params" in cols + assert "tools_used" in cols + + +def test_feedback_table() -> None: + assert AiFeedback.__table__.schema == "ai" + assert AiFeedback.__tablename__ == "ai_feedback" + cols = _column_names(AiFeedback) + assert {"user_id", "session_id", "message_id", "rating", "agent_type"}.issubset(cols) + + +def test_message_role_enum_values() -> None: + assert MessageRole.user.value == "user" + assert MessageRole.assistant.value == "assistant" + assert MessageRole.system.value == "system" + + +def test_all_three_tables_registered() -> None: + from am_user_platform.core.database import Base + + table_names = {t.name for t in Base.metadata.sorted_tables} + assert table_names == {"ai_sessions", "ai_messages", "ai_feedback"} + for table in Base.metadata.sorted_tables: + assert table.schema == "ai" + + +def test_session_indexes() -> None: + index_names = {index.name for index in AiSession.__table__.indexes} + assert "ix_ai_sessions_user_product_agent_updated" in index_names + + +def test_message_indexes() -> None: + index_names = {index.name for index in AiMessage.__table__.indexes} + assert "ix_ai_messages_session_created" in index_names + + +def test_foreign_keys() -> None: + session_fks = {fk.target_fullname for fk in AiMessage.__table__.foreign_keys} + assert "ai.ai_sessions.id" in session_fks + + +def test_session_model_instantiation() -> None: + session = AiSession( + id=uuid.uuid4(), + user_id="user-123", + product_id="am_app", + agent_type="fin_portfolio", + channel="user_app", + title="Portfolio chat", + ) + assert session.product_id == "am_app" + assert session.agent_type == "fin_portfolio" diff --git a/am-user-platform/tests/modules/ai/test_schemas.py b/am-user-platform/tests/modules/ai/test_schemas.py new file mode 100644 index 0000000..afaa416 --- /dev/null +++ b/am-user-platform/tests/modules/ai/test_schemas.py @@ -0,0 +1,148 @@ +"""Schema validation tests — no Postgres required.""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from am_user_platform.modules.ai.models.db import MessageRole +from am_user_platform.modules.ai.schemas.feedback import FeedbackCreate, FeedbackResponse +from am_user_platform.modules.ai.schemas.message import ( + AppendMessagesRequest, + ContextResponse, + MessageAppendItem, + MessageResponse, +) +from am_user_platform.modules.ai.schemas.session import ( + SessionCreate, + SessionListResponse, + SessionResponse, + SessionUpdate, +) + + +def test_session_create_from_json() -> None: + payload = SessionCreate.model_validate( + { + "product_id": "am_app", + "agent_type": "fin_portfolio", + "channel": "user_app", + "title": "My chat", + } + ) + assert payload.product_id == "am_app" + + +def test_session_update_from_json() -> None: + payload = SessionUpdate.model_validate({"title": "Renamed"}) + assert payload.title == "Renamed" + + +def test_append_messages_request_from_json() -> None: + payload = AppendMessagesRequest.model_validate( + { + "user_id": "user-1", + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [ + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": "Hi", + "tokens_used": 42, + "tools_used": ["get_portfolio_summary"], + }, + ], + } + ) + assert len(payload.messages) == 2 + assert payload.messages[1].tokens_used == 42 + + +def test_feedback_create_from_json() -> None: + session_id = uuid.uuid4() + payload = FeedbackCreate.model_validate( + { + "session_id": str(session_id), + "agent_type": "fin_portfolio", + "rating": "down", + "comment": "Wrong numbers", + } + ) + assert payload.rating == "down" + + +def test_session_response_round_trip() -> None: + now = datetime.now(timezone.utc) + session_id = uuid.uuid4() + dto = SessionResponse( + id=session_id, + user_id="user-1", + product_id="am_app", + agent_type="fin_portfolio", + channel="user_app", + title="Test", + created_at=now, + updated_at=now, + ) + data = dto.model_dump(mode="json") + assert SessionResponse.model_validate(data).id == session_id + + +def test_context_response_structure() -> None: + now = datetime.now(timezone.utc) + session_id = uuid.uuid4() + msg_id = uuid.uuid4() + ctx = ContextResponse( + session_id=session_id, + messages=[ + MessageResponse( + id=msg_id, + session_id=session_id, + role=MessageRole.user, + content="Hi", + created_at=now, + ) + ], + ) + assert ctx.messages[0].role == MessageRole.user + + +def test_session_list_response() -> None: + now = datetime.now(timezone.utc) + listed = SessionListResponse( + items=[ + SessionResponse( + id=uuid.uuid4(), + user_id="u1", + product_id="am_app", + agent_type="fin_portfolio", + channel="user_app", + title="Chat", + created_at=now, + updated_at=now, + ) + ], + total=1, + limit=50, + offset=0, + ) + assert listed.total == 1 + + +def test_feedback_response_from_attributes() -> None: + now = datetime.now(timezone.utc) + + class Row: + id = uuid.uuid4() + user_id = "user-1" + session_id = uuid.uuid4() + message_id = None + agent_type = "fin_portfolio" + rating = "up" + comment = None + trace_id = None + created_at = now + + dto = FeedbackResponse.model_validate(Row()) + assert dto.rating == "up" diff --git a/am-user-platform/tests/modules/ai/test_services.py b/am-user-platform/tests/modules/ai/test_services.py new file mode 100644 index 0000000..4ca8728 --- /dev/null +++ b/am-user-platform/tests/modules/ai/test_services.py @@ -0,0 +1,228 @@ +"""Service integration tests — require Postgres (skip if unavailable).""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest + +from am_platform_common import NotFoundError + +from am_user_platform.core.database import get_session_factory, init_db, ping_db +from am_user_platform.modules.ai.models.db import MessageRole +from am_user_platform.modules.ai.schemas.feedback import FeedbackCreate +from am_user_platform.modules.ai.schemas.message import AppendMessagesRequest, MessageAppendItem +from am_user_platform.modules.ai.schemas.session import SessionCreate +from am_user_platform.modules.ai.services.message_service import MessageService +from am_user_platform.modules.ai.services.session_service import SessionService + + +def _run(coro): + return asyncio.run(coro) + + +@pytest.fixture(scope="module") +def postgres_ready() -> bool: + if not _run(ping_db()): + return False + _run(init_db()) + return True + + +@pytest.fixture +def user_id() -> str: + return f"test-user-{uuid.uuid4()}" + + +@pytest.fixture +def other_user_id() -> str: + return f"test-user-{uuid.uuid4()}" + + +def test_session_list_scoped_to_user( + postgres_ready: bool, user_id: str, other_user_id: str +) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + async def _test() -> None: + factory = get_session_factory() + async with factory() as db: + sessions = SessionService(db) + await sessions.create_session( + user_id, + SessionCreate( + product_id="am_app", + agent_type="fin_portfolio", + ), + ) + await sessions.create_session( + other_user_id, + SessionCreate( + product_id="am_app", + agent_type="fin_portfolio", + ), + ) + listed = await sessions.list_sessions(user_id) + assert listed.total >= 1 + assert all(item.user_id == user_id for item in listed.items) + await db.commit() + await sessions.purge_user_data(user_id) + await sessions.purge_user_data(other_user_id) + await db.commit() + + _run(_test()) + + +def test_append_messages_and_context(postgres_ready: bool, user_id: str) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + async def _test() -> None: + factory = get_session_factory() + async with factory() as db: + sessions = SessionService(db) + messages = MessageService(db) + session_id = uuid.uuid4() + created = await sessions.create_session( + user_id, + SessionCreate( + id=session_id, + product_id="am_app", + agent_type="fin_portfolio", + ), + ) + before_updated = created.updated_at + + await messages.append_messages( + session_id, + AppendMessagesRequest( + user_id=user_id, + product_id="am_app", + agent_type="fin_portfolio", + messages=[ + MessageAppendItem(role=MessageRole.user, content="Portfolio?"), + MessageAppendItem( + role=MessageRole.assistant, + content="Here is your summary", + tokens_used=1200, + ), + ], + ), + ) + await db.flush() + + refreshed = await sessions.get_session(session_id, user_id) + assert refreshed.updated_at >= before_updated + assert refreshed.title == "Portfolio?" + + ctx = await messages.get_context(session_id, user_id, limit=5) + assert len(ctx.messages) == 2 + assert ctx.messages[0].role == MessageRole.user + assert ctx.messages[1].tokens_used == 1200 + + await db.commit() + await sessions.purge_user_data(user_id) + await db.commit() + + _run(_test()) + + +def test_get_context_returns_last_n_in_order(postgres_ready: bool, user_id: str) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + async def _test() -> None: + factory = get_session_factory() + async with factory() as db: + sessions = SessionService(db) + messages = MessageService(db) + session_id = uuid.uuid4() + await sessions.create_session( + user_id, + SessionCreate( + id=session_id, + product_id="am_app", + agent_type="fin_portfolio", + ), + ) + batch = AppendMessagesRequest( + user_id=user_id, + product_id="am_app", + agent_type="fin_portfolio", + messages=[ + MessageAppendItem(role=MessageRole.user, content=f"msg-{i}") + for i in range(6) + ], + ) + await messages.append_messages(session_id, batch) + + ctx = await messages.get_context(session_id, user_id, limit=3) + assert len(ctx.messages) == 3 + assert ctx.messages[0].content == "msg-3" + assert ctx.messages[-1].content == "msg-5" + + await db.commit() + await sessions.purge_user_data(user_id) + await db.commit() + + _run(_test()) + + +def test_tenancy_get_session(postgres_ready: bool, user_id: str, other_user_id: str) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + async def _test() -> None: + factory = get_session_factory() + async with factory() as db: + sessions = SessionService(db) + created = await sessions.create_session( + user_id, + SessionCreate( + product_id="am_app", + agent_type="fin_portfolio", + ), + ) + with pytest.raises(NotFoundError): + await sessions.get_session(created.id, other_user_id) + await db.commit() + await sessions.purge_user_data(user_id) + await db.commit() + + _run(_test()) + + +def test_feedback_create(postgres_ready: bool, user_id: str) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + async def _test() -> None: + from am_user_platform.modules.ai.services.feedback_service import FeedbackService + + factory = get_session_factory() + async with factory() as db: + sessions = SessionService(db) + feedback = FeedbackService(db) + created = await sessions.create_session( + user_id, + SessionCreate( + product_id="am_app", + agent_type="fin_portfolio", + ), + ) + row = await feedback.create_feedback( + user_id, + FeedbackCreate( + session_id=created.id, + agent_type="fin_portfolio", + rating="up", + ), + ) + assert row.rating == "up" + await db.commit() + await sessions.purge_user_data(user_id) + await db.commit() + + _run(_test()) diff --git a/am-user-platform/tests/modules/ai/test_sessions.py b/am-user-platform/tests/modules/ai/test_sessions.py new file mode 100644 index 0000000..2a2b571 --- /dev/null +++ b/am-user-platform/tests/modules/ai/test_sessions.py @@ -0,0 +1,105 @@ +"""C0.48 — create, list, update title, soft delete.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from am_platform_common import NotFoundError + +from am_user_platform.core.database import get_session_factory +from am_user_platform.modules.ai.schemas.session import SessionCreate +from am_user_platform.modules.ai.services.session_service import SessionService + + +def _run(coro): + return asyncio.run(coro) + + +def test_create_list_update_soft_delete( + postgres_ready: bool, user_id: str +) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + async def _test() -> None: + factory = get_session_factory() + async with factory() as db: + sessions = SessionService(db) + created = await sessions.create_session( + user_id, + SessionCreate( + product_id="am_app", + agent_type="fin_portfolio", + title="First chat", + ), + ) + assert created.title == "First chat" + assert created.user_id == user_id + + listed = await sessions.list_sessions( + user_id, product_id="am_app", agent_type="fin_portfolio" + ) + assert listed.total >= 1 + assert any(item.id == created.id for item in listed.items) + + renamed = await sessions.update_title( + created.id, user_id, "Renamed chat" + ) + assert renamed.title == "Renamed chat" + + await sessions.soft_delete(created.id, user_id) + with pytest.raises(NotFoundError): + await sessions.get_session(created.id, user_id) + + after = await sessions.list_sessions(user_id) + assert all(item.id != created.id for item in after.items) + + await db.commit() + await sessions.purge_user_data(user_id) + await db.commit() + + _run(_test()) + + +def test_create_session_via_user_api( + client, postgres_ready: bool, api_user_id: str +) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + created = client.post( + "/v1/user-platform/ai/sessions", + json={ + "product_id": "am_app", + "agent_type": "fin_portfolio", + "title": "API session", + }, + ) + assert created.status_code == 201 + session_id = created.json()["data"]["id"] + + listed = client.get( + "/v1/user-platform/ai/sessions", + params={"product_id": "am_app", "agent_type": "fin_portfolio"}, + ) + assert listed.status_code == 200 + assert any(item["id"] == session_id for item in listed.json()["data"]["items"]) + + patched = client.patch( + f"/v1/user-platform/ai/sessions/{session_id}", + json={"title": "Patched"}, + ) + assert patched.status_code == 200 + assert patched.json()["data"]["title"] == "Patched" + + deleted = client.delete(f"/v1/user-platform/ai/sessions/{session_id}") + assert deleted.status_code == 204 + + from am_user_platform.main import app + from am_user_platform.modules.ai.api.internal_router import ServiceAuth + from tests.conftest import service_context + + app.dependency_overrides[ServiceAuth] = service_context + client.delete(f"/internal/ai/users/{api_user_id}/data") diff --git a/am-user-platform/tests/modules/ai/test_tenancy.py b/am-user-platform/tests/modules/ai/test_tenancy.py new file mode 100644 index 0000000..fd2ed62 --- /dev/null +++ b/am-user-platform/tests/modules/ai/test_tenancy.py @@ -0,0 +1,89 @@ +"""C0.51 — user A cannot read/delete user B session.""" + +from __future__ import annotations + +import asyncio +import uuid + +import pytest + +from am_platform_common import NotFoundError + +from am_user_platform.core.database import get_session_factory +from am_user_platform.main import app +from am_user_platform.modules.ai.api.session_router import UserAuth +from am_user_platform.modules.ai.schemas.session import SessionCreate +from am_user_platform.modules.ai.services.session_service import SessionService +from tests.conftest import user_context + + +def _run(coro): + return asyncio.run(coro) + + +def test_tenancy_get_and_delete_session( + postgres_ready: bool, user_id: str, other_user_id: str +) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + async def _test() -> None: + factory = get_session_factory() + async with factory() as db: + sessions = SessionService(db) + created = await sessions.create_session( + user_id, + SessionCreate( + product_id="am_app", + agent_type="fin_portfolio", + ), + ) + with pytest.raises(NotFoundError): + await sessions.get_session(created.id, other_user_id) + with pytest.raises(NotFoundError): + await sessions.soft_delete(created.id, other_user_id) + + still = await sessions.get_session(created.id, user_id) + assert still.id == created.id + + listed = await sessions.list_sessions(other_user_id) + assert all(item.id != created.id for item in listed.items) + + await db.commit() + await sessions.purge_user_data(user_id) + await db.commit() + + _run(_test()) + + +def test_user_api_tenancy(client, postgres_ready: bool) -> None: + if not postgres_ready: + pytest.skip("Postgres not available") + + owner_id = f"owner-{uuid.uuid4()}" + other_id = f"other-{uuid.uuid4()}" + session_id = uuid.uuid4() + + created = client.post( + f"/internal/ai/sessions/{session_id}/messages", + json={ + "user_id": owner_id, + "product_id": "am_app", + "agent_type": "fin_portfolio", + "messages": [{"role": "user", "content": "Hello"}], + }, + ) + assert created.status_code == 201 + + app.dependency_overrides[UserAuth] = lambda: user_context(other_id) + denied = client.get(f"/v1/user-platform/ai/sessions/{session_id}") + assert denied.status_code == 404 + + deleted = client.delete(f"/v1/user-platform/ai/sessions/{session_id}") + assert deleted.status_code == 404 + + app.dependency_overrides[UserAuth] = lambda: user_context(owner_id) + owned = client.get(f"/v1/user-platform/ai/sessions/{session_id}") + assert owned.status_code == 200 + + client.delete(f"/internal/ai/users/{owner_id}/data") diff --git a/automation/helm/lago-plans.json b/automation/helm/lago-plans.json index a7d4fdc..a25db75 100644 --- a/automation/helm/lago-plans.json +++ b/automation/helm/lago-plans.json @@ -25,6 +25,13 @@ "name": "API Calls", "description": "Gateway and service API calls for metering", "aggregation_type": "count_agg" + }, + { + "code": "ai_chat_tokens", + "name": "AI Chat Tokens", + "description": "LLM tokens consumed in AI chat per billing period", + "aggregation_type": "sum_agg", + "field_name": "tokens" } ], "plans": [ @@ -46,7 +53,8 @@ "document_parses": 5, "portfolios": 1, "ai_portfolio_summaries": 1, - "api_calls": 1000 + "api_calls": 1000, + "ai_chat_tokens": 100000 }, "entitlements": { "live_market_data": false, @@ -77,7 +85,8 @@ "document_parses": 50, "portfolios": 5, "ai_portfolio_summaries": 20, - "api_calls": 50000 + "api_calls": 50000, + "ai_chat_tokens": 1000000 }, "entitlements": { "live_market_data": true, @@ -103,7 +112,8 @@ "document_parses": 50, "portfolios": 5, "ai_portfolio_summaries": 20, - "api_calls": 50000 + "api_calls": 50000, + "ai_chat_tokens": 1000000 }, "entitlements": { "live_market_data": true, @@ -135,7 +145,8 @@ "document_parses": 200, "portfolios": 20, "ai_portfolio_summaries": 100, - "api_calls": 250000 + "api_calls": 250000, + "ai_chat_tokens": 1000000 }, "entitlements": { "live_market_data": true, @@ -162,7 +173,8 @@ "document_parses": 200, "portfolios": 20, "ai_portfolio_summaries": 100, - "api_calls": 250000 + "api_calls": 250000, + "ai_chat_tokens": 1000000 }, "entitlements": { "live_market_data": true, diff --git a/automation/scripts/platform_env.py b/automation/scripts/platform_env.py index e768104..ee2b089 100644 --- a/automation/scripts/platform_env.py +++ b/automation/scripts/platform_env.py @@ -28,6 +28,12 @@ PLATFORM_ROOT / "am-notification", ] +USER_PLATFORM_LIB_PATHS = [ + PLATFORM_ROOT / "libraries" / "am-platform-common", + PLATFORM_ROOT / "libraries" / "am-platform-security", + PLATFORM_ROOT / "am-user-platform", +] + def notification_env() -> dict[str, str]: env = os.environ.copy() @@ -116,3 +122,16 @@ def subscription_env() -> dict[str, str]: env["AM_SUBSCRIPTION_POSTGRES_HOST"] = "postgres.asrax.in" env.setdefault("AM_SUBSCRIPTION_POSTGRES_PORT", "8891") return env + + +def user_platform_env() -> dict[str, str]: + env = os.environ.copy() + env.update(load_env_files()) + env["PYTHONPATH"] = os.pathsep.join(str(p) for p in USER_PLATFORM_LIB_PATHS) + env["APP_NAME"] = "am-user-platform" + env["APP_PORT"] = "8115" + pg_host = env.get("POSTGRES_HOST", "") + if ".svc.cluster.local" in pg_host and not env.get("AM_USER_PLATFORM_POSTGRES_HOST"): + env["AM_USER_PLATFORM_POSTGRES_HOST"] = "postgres.asrax.in" + env.setdefault("AM_USER_PLATFORM_POSTGRES_PORT", "8891") + return env diff --git a/automation/scripts/provision_lago_plans.py b/automation/scripts/provision_lago_plans.py index 12a0553..c9f1a4f 100644 --- a/automation/scripts/provision_lago_plans.py +++ b/automation/scripts/provision_lago_plans.py @@ -133,6 +133,7 @@ def build_plan_payload(plan: dict, currency: str, metric_ids: dict[str, str]) -> ("portfolios", limits.get("portfolios")), ("ai_portfolio_summaries", limits.get("ai_portfolio_summaries")), ("api_calls", limits.get("api_calls")), + ("ai_chat_tokens", limits.get("ai_chat_tokens")), ): if not limit: continue @@ -161,9 +162,10 @@ def ensure_plan( base_url: str, api_key: str, plan: dict, currency: str, metric_ids: dict[str, str] ) -> None: code = plan["code"] - status, _ = api_request(base_url, api_key, "GET", f"/api/v1/plans/{code}") + status, existing = api_request(base_url, api_key, "GET", f"/api/v1/plans/{code}") if status == 200: - print(f" plan {code}: exists (skip)") + print(f" plan {code}: exists") + ensure_plan_charges(base_url, api_key, plan, existing, metric_ids) return payload = build_plan_payload(plan, currency, metric_ids) status, result = api_request(base_url, api_key, "POST", "/api/v1/plans", payload) @@ -173,6 +175,86 @@ def ensure_plan( print(f" plan {code}: created (INR {amount}/{plan['interval']})") +def ensure_plan_charges( + base_url: str, + api_key: str, + plan: dict, + existing: dict | list | str, + metric_ids: dict[str, str], +) -> None: + """Add any catalog charges missing on an existing Lago plan (e.g. new metrics).""" + if not isinstance(existing, dict): + print(f" plan {plan['code']}: unexpected GET payload, skip charge sync") + return + plan_body = existing.get("plan") if isinstance(existing.get("plan"), dict) else existing + code = plan["code"] + existing_codes = set() + for c in plan_body.get("charges") or []: + if not isinstance(c, dict): + continue + if c.get("billable_metric_code"): + existing_codes.add(c["billable_metric_code"]) + bm = c.get("billable_metric") or {} + if isinstance(bm, dict) and bm.get("code"): + existing_codes.add(bm["code"]) + + limits = plan.get("limits", {}) + missing: list[tuple[str, int]] = [] + for metric_code, limit in ( + ("document_parses", limits.get("document_parses")), + ("portfolios", limits.get("portfolios")), + ("ai_portfolio_summaries", limits.get("ai_portfolio_summaries")), + ("api_calls", limits.get("api_calls")), + ("ai_chat_tokens", limits.get("ai_chat_tokens")), + ): + if limit and metric_code not in existing_codes: + missing.append((metric_code, int(limit))) + if not missing: + print(f" plan {code}: charges already up to date") + return + + charges: list[dict] = [] + for c in plan_body.get("charges") or []: + if not isinstance(c, dict): + continue + charges.append( + { + "lago_id": c["lago_id"], + "billable_metric_id": c["lago_billable_metric_id"], + "charge_model": c["charge_model"], + "invoiceable": c.get("invoiceable", True), + "pay_in_advance": c.get("pay_in_advance", False), + "prorated": c.get("prorated", False), + "min_amount_cents": c.get("min_amount_cents", 0), + "properties": c.get("properties") or {}, + } + ) + for metric_code, limit in missing: + metric_id = metric_ids.get(metric_code) + if not metric_id: + raise RuntimeError(f"Billable metric not found: {metric_code}") + charges.append(package_charge(metric_id, metric_code, limit)) + + payload = { + "plan": { + "name": plan_body["name"], + "code": plan_body["code"], + "interval": plan_body["interval"], + "amount_cents": plan_body["amount_cents"], + "amount_currency": plan_body["amount_currency"], + "pay_in_advance": plan_body.get("pay_in_advance", False), + "trial_period": plan_body.get("trial_period", 0), + "description": plan_body.get("description") or "", + "charges": charges, + } + } + status, result = api_request(base_url, api_key, "PUT", f"/api/v1/plans/{code}", payload) + if status not in (200, 201): + raise RuntimeError(f"Failed to update charges on {code}: {status} {result}") + added = ", ".join(m for m, _ in missing) + print(f" plan {code}: added charge(s) {added}") + + def main() -> int: env = load_env() base_url = env.get("LAGO_API_URL", "https://lago.munish.org") diff --git a/automation/scripts/run_platform.py b/automation/scripts/run_platform.py index 0fd368c..3b395ce 100644 --- a/automation/scripts/run_platform.py +++ b/automation/scripts/run_platform.py @@ -12,7 +12,13 @@ from collections.abc import Callable from dataclasses import dataclass -from platform_env import PLATFORM_ROOT, identity_env, notification_env, subscription_env +from platform_env import ( + PLATFORM_ROOT, + identity_env, + notification_env, + subscription_env, + user_platform_env, +) from uvicorn_runner import build_uvicorn_args @@ -43,6 +49,13 @@ class ServiceSpec: notification_env, "8111", ), + ServiceSpec( + "user-platform", + "am_user_platform.main:app", + "am-user-platform", + user_platform_env, + "8115", + ), ) @@ -171,7 +184,7 @@ def run_platform(*, reload: bool) -> int: labels = ", ".join(f"{spec.name}:{spec.port}" for spec in specs) print(f"Platform dev - {labels}") if parse_service_filter() is None: - print("Tip: set PLATFORM_DEV_SERVICES=identity,subscription to run a subset.\n") + print("Tip: set PLATFORM_DEV_SERVICES=identity,subscription,user-platform to run a subset.\n") return PlatformSupervisor(specs, reload=reload).run() diff --git a/automation/scripts/run_service.py b/automation/scripts/run_service.py index 8df27eb..6c7cb49 100644 --- a/automation/scripts/run_service.py +++ b/automation/scripts/run_service.py @@ -6,7 +6,13 @@ import subprocess import sys -from platform_env import PLATFORM_ROOT, identity_env, notification_env, subscription_env +from platform_env import ( + PLATFORM_ROOT, + identity_env, + notification_env, + subscription_env, + user_platform_env, +) from uvicorn_runner import build_uvicorn_args @@ -46,10 +52,19 @@ def run_notification(*, reload: bool) -> int: ) +def run_user_platform(*, reload: bool) -> int: + return run_uvicorn( + "am_user_platform.main:app", + reload=reload, + env=user_platform_env(), + cwd_name="am-user-platform", + ) + + def main() -> None: if len(sys.argv) < 2: print( - "Usage: run_service.py " + "Usage: run_service.py " ) sys.exit(1) @@ -65,6 +80,8 @@ def main() -> None: sys.exit(run_subscription(reload=reload)) if service == "notification": sys.exit(run_notification(reload=reload)) + if service == "user-platform": + sys.exit(run_user_platform(reload=reload)) print(f"Unknown service: {service}") sys.exit(1) diff --git a/automation/scripts/run_user_platform_tests.py b/automation/scripts/run_user_platform_tests.py new file mode 100644 index 0000000..8df1aa9 --- /dev/null +++ b/automation/scripts/run_user_platform_tests.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Run am-user-platform tests with monorepo PYTHONPATH.""" + +from __future__ import annotations + +import subprocess +import sys + +from platform_env import PLATFORM_ROOT, python_exe, user_platform_env + + +def main() -> int: + env = user_platform_env() + args = [python_exe(), "-m", "pytest", "tests", "-q", *sys.argv[1:]] + return subprocess.run(args, cwd=PLATFORM_ROOT / "am-user-platform", env=env).returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/check_java_platform.js b/check_java_platform.js new file mode 100644 index 0000000..3c23979 --- /dev/null +++ b/check_java_platform.js @@ -0,0 +1,31 @@ +import fs from 'fs'; +import path from 'path'; + +function walkDir(dir, callback) { + fs.readdirSync(dir).forEach(f => { + let dirPath = path.join(dir, f); + let isDirectory = fs.statSync(dirPath).isDirectory(); + if (isDirectory) { + if (f !== 'node_modules' && f !== '.git' && f !== 'venv' && f !== '.ruff_cache') { + walkDir(dirPath, callback); + } + } else { + callback(dirPath); + } + }); +} + +const root = 'C:/Users/adhik/Downloads/Asrax/AM/am-platform'; +let hasJava = false; +let hasPom = false; +walkDir(root, (filePath) => { + if (filePath.endsWith('.java')) { + hasJava = true; + } + if (filePath.endsWith('pom.xml')) { + hasPom = true; + } +}); + +console.log(`Java files found: ${hasJava}`); +console.log(`pom.xml found: ${hasPom}`); diff --git a/package.json b/package.json index 1aa07b5..6a81650 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "am-identity", "am-subscription", "am-notification", + "am-user-platform", "libraries/am-platform-common", "libraries/am-platform-security" ], @@ -45,6 +46,12 @@ "notification:lint": "npm run lint -w @am-platform/notification", "notification:compile": "npm run compile -w @am-platform/notification", + "user-platform:dev": "npm run dev -w @am-platform/user-platform", + "user-platform:start": "npm run user-platform:dev", + "user-platform:lint": "npm run lint -w @am-platform/user-platform", + "user-platform:compile": "npm run compile -w @am-platform/user-platform", + "user-platform:test": "npm run test -w @am-platform/user-platform", + "common:test": "npm run test -w @am-platform/common", "common:lint": "npm run lint -w @am-platform/common", "security:test": "npm run test -w @am-platform/security", @@ -85,9 +92,11 @@ "run:identity:prod": "npm run identity:dev:prod", "run:subscription": "npm run subscription:dev", "run:notification": "npm run notification:dev", + "run:user-platform": "npm run user-platform:dev", "dev:notification": "npm run notification:dev", "dev:subscription": "npm run subscription:dev", "dev:identity": "npm run identity:dev", + "dev:user-platform": "npm run user-platform:dev", "test:common": "npm run common:test", "test:security": "npm run security:test", "lint:fix": "node automation/scripts/run-with-logs.js npm run lint:fix --workspaces --if-present", diff --git a/search_enums.js b/search_enums.js new file mode 100644 index 0000000..e60b50d --- /dev/null +++ b/search_enums.js @@ -0,0 +1,32 @@ +import fs from 'fs'; +import path from 'path'; + +function walkDir(dir, callback) { + fs.readdirSync(dir).forEach(f => { + let dirPath = path.join(dir, f); + let isDirectory = fs.statSync(dirPath).isDirectory(); + if (isDirectory) { + if (f !== 'node_modules' && f !== '.git' && f !== 'venv' && f !== '.ruff_cache') { + walkDir(dirPath, callback); + } + } else { + callback(dirPath); + } + }); +} + +const root = 'C:/Users/adhik/Downloads/Asrax/AM/am-platform'; +walkDir(root, (filePath) => { + if (filePath.endsWith('.py')) { + let content = fs.readFileSync(filePath, 'utf8'); + if (content.includes('class ') && content.includes('Enum')) { + console.log(`File: ${filePath}`); + const lines = content.split('\n'); + lines.forEach((line, index) => { + if (line.includes('class ') && line.includes('Enum')) { + console.log(` Line ${index + 1}: ${line.trim()}`); + } + }); + } + } +}); diff --git a/spt/identity-spt.js b/spt/identity-spt.js new file mode 100644 index 0000000..1542947 --- /dev/null +++ b/spt/identity-spt.js @@ -0,0 +1,93 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +const allScenarios = { + baseline: { + executor: 'constant-vus', + vus: 1, + duration: '1m', + }, + load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '1m', target: 10 }, + { duration: '3m', target: 10 }, + { duration: '1m', target: 0 }, + ], + }, + stress: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '1m', target: 50 }, + { duration: '2m', target: 50 }, + { duration: '1m', target: 0 }, + ], + }, +}; + +const selectedScenario = __ENV.SCENARIO; + +export const options = { + scenarios: selectedScenario ? { [selectedScenario]: allScenarios[selectedScenario] } : allScenarios, + thresholds: { + http_req_failed: ['rate<0.01'], // Fail rate under 1% + }, +}; + +let token = null; + +export default function () { + const baseUrl = __ENV.BASE_URL || 'https://am-dev.asrax.in'; + const vuId = __VU; + + // Login dynamically + if (!token) { + const loginPayload = JSON.stringify({ + username: `spt-user-${vuId}@example.com`, + password: 'SptPassword.1', + }); + const loginRes = http.post(`${baseUrl}/identity/auth/login`, loginPayload, { + headers: { 'Content-Type': 'application/json' }, + }); + + if (loginRes.status === 200) { + token = loginRes.json().access_token; + } else { + console.error(`VU ${vuId} failed to log in: ${loginRes.status}`); + sleep(1); + return; + } + } + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; + + // 1. Fetch Profile + const profileRes = http.get(`${baseUrl}/identity/users/me`, { headers }); + check(profileRes, { + 'identity profile is 200': (r) => r.status === 200, + }); + + sleep(1); + + // 2. Request Deletion + const deletionPayload = JSON.stringify({ feedback: 'Identity isolated SPT' }); + const deleteRes = http.post(`${baseUrl}/identity/users/me/request-deletion`, deletionPayload, { headers }); + check(deleteRes, { + 'identity request deletion is 200': (r) => r.status === 200, + }); + + sleep(1); + + // 3. Restore Account + const restoreRes = http.post(`${baseUrl}/identity/users/me/restore`, null, { headers }); + check(restoreRes, { + 'identity restore is 200': (r) => r.status === 200, + }); + + sleep(2); +} diff --git a/spt/run_all_spts.py b/spt/run_all_spts.py new file mode 100644 index 0000000..ed318f1 --- /dev/null +++ b/spt/run_all_spts.py @@ -0,0 +1,97 @@ +import subprocess +import os +import re +import json + +k6_path = r"C:\Program Files\k6\k6.exe" +base_url = "https://am-dev.asrax.in" +spt_dir = r"C:\Users\adhik\Downloads\Asrax\AM\am-platform" + +scenarios = [ + {"service": "Identity", "script": "spt/identity-spt.js", "name": "baseline"}, + {"service": "Identity", "script": "spt/identity-spt.js", "name": "load"}, + {"service": "Identity", "script": "spt/identity-spt.js", "name": "stress"}, + {"service": "Subscription", "script": "spt/subscription-spt.js", "name": "baseline"}, + {"service": "Subscription", "script": "spt/subscription-spt.js", "name": "load"}, + {"service": "Subscription", "script": "spt/subscription-spt.js", "name": "stress"} +] + +results = [] + +def run_k6(service, script, scenario): + cmd = [ + k6_path, "run", + "--insecure-skip-tls-verify", + "--env", f"SCENARIO={scenario}", + "--env", f"BASE_URL={base_url}", + script + ] + print(f"=== Running {service} - {scenario} ===") + + # Run process and capture output + process = subprocess.run(cmd, cwd=spt_dir, capture_output=True, text=True) + output = process.stdout + "\n" + process.stderr + + # Save raw output to log file + log_name = f"spt_{service.lower()}_{scenario}.log" + with open(os.path.join(spt_dir, "spt", log_name), "w", encoding="utf-8") as f: + f.write(output) + + print(f"Finished {service} - {scenario}. Logs saved to spt/{log_name}") + + # Parse metrics from output + metrics = { + "service": service, + "scenario": scenario, + "checks_pct": "0.00%", + "req_failed_pct": "100.00%", + "avg_lat": "0s", + "p95_lat": "0s", + "reqs_sec": "0/s", + "status": "Failed" + } + + # Extract Checks Percentage + checks_match = re.search(r"checks_succeeded\.*:\s+([\d\.]+)%", output) + if checks_match: + metrics["checks_pct"] = f"{checks_match.group(1)}%" + + # Extract Request Fail Rate + fail_match = re.search(r"http_req_failed\.*:\s+([\d\.]+)%", output) + if fail_match: + metrics["req_failed_pct"] = f"{fail_match.group(1)}%" + + # Extract Latencies + duration_match = re.search(r"http_req_duration\.*:\s+avg=([^\s]+)\s+min=[^\s]+\s+med=[^\s]+\s+max=[^\s]+\s+p\(90\)=[^\s]+\s+p\(95\)=([^\s]+)", output) + if duration_match: + metrics["avg_lat"] = duration_match.group(1) + metrics["p95_lat"] = duration_match.group(2) + + # Extract requests per second + reqs_match = re.search(r"http_reqs\.*:\s+\d+\s+([\d\.]+)/s", output) + if reqs_match: + metrics["reqs_sec"] = f"{reqs_match.group(1)}/s" + + # Check if threshold crossed + if "thresholds on metrics" in output: + metrics["status"] = "Threshold Crossed" + else: + metrics["status"] = "Passed" + + results.append(metrics) + +def main(): + # Make sure spt folder exists + os.makedirs(os.path.join(spt_dir, "spt"), exist_ok=True) + + for sc in scenarios: + run_k6(sc["service"], sc["script"], sc["name"]) + + # Write JSON results summary + with open(os.path.join(spt_dir, "spt", "spt_results_summary.json"), "w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + + print("=== ALL SPT RUNS COMPLETE ===") + +if __name__ == "__main__": + main() diff --git a/spt/seed_spt_users.py b/spt/seed_spt_users.py new file mode 100644 index 0000000..deb64e8 --- /dev/null +++ b/spt/seed_spt_users.py @@ -0,0 +1,75 @@ +import os +import httpx +import logging + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger("seed_spt") + +async def get_admin_token(client: httpx.AsyncClient, keycloak_url: str, admin_user: str, admin_password: str) -> str: + url = f"{keycloak_url}/realms/master/protocol/openid-connect/token" + data = { + "grant_type": "password", + "client_id": "admin-cli", + "username": admin_user, + "password": admin_password, + } + response = await client.post(url, data=data) + response.raise_for_status() + return response.json()["access_token"] + +async def create_user(client: httpx.AsyncClient, keycloak_url: str, realm: str, token: str, username: str, email: str) -> None: + url = f"{keycloak_url}/admin/realms/{realm}/users" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + user_payload = { + "username": username, + "email": email, + "enabled": True, + "emailVerified": True, + "credentials": [ + { + "type": "password", + "value": "SptPassword.1", + "temporary": False + } + ] + } + response = await client.post(url, headers=headers, json=user_payload) + if response.status_code == 201: + logger.info(f"Successfully created user: {username}") + elif response.status_code == 409: + logger.info(f"User {username} already exists (skipping)") + else: + logger.error(f"Failed to create user {username}: {response.status_code} - {response.text}") + +async def main(): + keycloak_url = os.environ.get("KEYCLOAK_URL", "http://localhost:9080").rstrip("/") + realm = os.environ.get("KEYCLOAK_REALM", "am") + admin_user = os.environ.get("KEYCLOAK_ADMIN_USER", "admin") + admin_password = os.environ.get("KEYCLOAK_ADMIN_PASSWORD", "admin") + verify_ssl = os.environ.get("IDENTITY_VERIFY_SSL", "false").lower() in ("1", "true", "yes") + + num_users = 100 + + logger.info("Initializing Keycloak seeding for SPT...") + async with httpx.AsyncClient(verify=verify_ssl) as client: + try: + token = await get_admin_token(client, keycloak_url, admin_user, admin_password) + logger.info("Obtained admin token from Keycloak.") + except Exception as e: + logger.error(f"Failed to obtain admin token: {e}") + return + + for i in range(1, num_users + 1): + username = f"spt-user-{i}" + email = f"spt-user-{i}@example.com" + try: + await create_user(client, keycloak_url, realm, token, username, email) + except Exception as e: + logger.error(f"Failed to seed user {username}: {e}") + +if __name__ == "__main__": + import asyncio + asyncio.run(main()) diff --git a/spt/spt-test.js b/spt/spt-test.js new file mode 100644 index 0000000..39a1ddf --- /dev/null +++ b/spt/spt-test.js @@ -0,0 +1,91 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +// Define the scenarios for the performance test +export const options = { + scenarios: { + baseline: { + executor: 'constant-vus', + vus: 1, + duration: '1m', + }, + load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '1m', target: 10 }, // Warm up + { duration: '3m', target: 10 }, // Hold steady + { duration: '1m', target: 0 }, // Ramp down + ], + }, + stress: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '1m', target: 50 }, // Heavy ramp-up + { duration: '2m', target: 50 }, // Hold stress load + { duration: '1m', target: 0 }, // Cool down + ], + }, + }, + thresholds: { + http_req_failed: ['rate<0.01'], // Fail rate must be under 1% + }, +}; + +// Keep track of the token per Virtual User (VU) thread +let token = null; + +export default function () { + const baseUrl = __ENV.BASE_URL || 'http://localhost:8000'; + const vuId = __VU; // Unique ID for this Virtual User thread (1, 2, 3...) + + // 1. Authenticate dynamically as a unique seeded user + if (!token) { + const loginPayload = JSON.stringify({ + username: `spt-user-${vuId}@example.com`, + password: 'SptPassword.1', + }); + const loginRes = http.post(`${baseUrl}/identity/auth/login`, loginPayload, { + headers: { 'Content-Type': 'application/json' }, + }); + + if (loginRes.status === 200) { + token = loginRes.json().access_token; + } else { + console.error(`VU ${vuId} failed to log in: ${loginRes.status} - ${loginRes.body}`); + sleep(1); + return; + } + } + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; + + // 2. Fetch User Profile + const profileRes = http.get(`${baseUrl}/identity/users/me`, { headers }); + check(profileRes, { + 'profile status is 200': (r) => r.status === 200, + }); + + sleep(1); + + // 3. Request Account Deletion + const deletionPayload = JSON.stringify({ feedback: 'SPT load test unique feedback' }); + const deleteRes = http.post(`${baseUrl}/identity/users/me/request-deletion`, deletionPayload, { headers }); + check(deleteRes, { + 'request deletion status is 200': (r) => r.status === 200, + }); + + sleep(1); + + // 4. Restore Account + const restoreRes = http.post(`${baseUrl}/identity/users/me/restore`, null, { headers }); + check(restoreRes, { + 'restore status is 200': (r) => r.status === 200, + }); + + sleep(2); +} diff --git a/spt/spt_identity_baseline.log b/spt/spt_identity_baseline.log new file mode 100644 index 0000000..9d105c3 --- /dev/null +++ b/spt/spt_identity_baseline.log @@ -0,0 +1,235 @@ + + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + + execution: local + script: spt/identity-spt.js + output: - + + scenarios: (100.00%) 1 scenario, 1 max VUs, 1m30s max duration (incl. graceful stop): + * baseline: 1 looping VUs for 1m0s (gracefulStop: 30s) + + +running (0m01.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 2% ] 1 VUs 0m01.0s/1m0s + +running (0m02.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 3% ] 1 VUs 0m02.0s/1m0s + +running (0m03.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 5% ] 1 VUs 0m03.0s/1m0s + +running (0m04.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 7% ] 1 VUs 0m04.0s/1m0s + +running (0m05.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 8% ] 1 VUs 0m05.0s/1m0s + +running (0m06.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 10% ] 1 VUs 0m06.0s/1m0s + +running (0m07.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 12% ] 1 VUs 0m07.0s/1m0s + +running (0m08.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 13% ] 1 VUs 0m08.0s/1m0s + +running (0m09.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 15% ] 1 VUs 0m09.0s/1m0s + +running (0m10.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 17% ] 1 VUs 0m10.0s/1m0s + +running (0m11.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 18% ] 1 VUs 0m11.0s/1m0s + +running (0m12.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 20% ] 1 VUs 0m12.0s/1m0s + +running (0m13.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 22% ] 1 VUs 0m13.0s/1m0s + +running (0m14.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 23% ] 1 VUs 0m14.0s/1m0s + +running (0m15.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 25% ] 1 VUs 0m15.0s/1m0s + +running (0m16.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 27% ] 1 VUs 0m16.0s/1m0s + +running (0m17.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 28% ] 1 VUs 0m17.0s/1m0s + +running (0m18.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 30% ] 1 VUs 0m18.0s/1m0s + +running (0m19.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 32% ] 1 VUs 0m19.0s/1m0s + +running (0m20.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 33% ] 1 VUs 0m20.0s/1m0s + +running (0m21.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 35% ] 1 VUs 0m21.0s/1m0s + +running (0m22.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 37% ] 1 VUs 0m22.0s/1m0s + +running (0m23.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 38% ] 1 VUs 0m23.0s/1m0s + +running (0m24.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 40% ] 1 VUs 0m24.0s/1m0s + +running (0m25.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 42% ] 1 VUs 0m25.0s/1m0s + +running (0m26.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 43% ] 1 VUs 0m26.0s/1m0s + +running (0m27.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 45% ] 1 VUs 0m27.0s/1m0s + +running (0m28.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 47% ] 1 VUs 0m28.0s/1m0s + +running (0m29.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 48% ] 1 VUs 0m29.0s/1m0s + +running (0m30.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 50% ] 1 VUs 0m30.0s/1m0s + +running (0m31.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 52% ] 1 VUs 0m31.0s/1m0s + +running (0m32.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 53% ] 1 VUs 0m32.0s/1m0s + +running (0m33.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 55% ] 1 VUs 0m33.0s/1m0s + +running (0m34.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 57% ] 1 VUs 0m34.0s/1m0s + +running (0m35.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 58% ] 1 VUs 0m35.0s/1m0s + +running (0m36.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 60% ] 1 VUs 0m36.0s/1m0s + +running (0m37.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 62% ] 1 VUs 0m37.0s/1m0s + +running (0m38.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 63% ] 1 VUs 0m38.0s/1m0s + +running (0m39.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 65% ] 1 VUs 0m39.0s/1m0s + +running (0m40.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 67% ] 1 VUs 0m40.0s/1m0s + +running (0m41.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 68% ] 1 VUs 0m41.0s/1m0s + +running (0m42.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 70% ] 1 VUs 0m42.0s/1m0s + +running (0m43.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 72% ] 1 VUs 0m43.0s/1m0s + +running (0m44.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 73% ] 1 VUs 0m44.0s/1m0s + +running (0m45.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 75% ] 1 VUs 0m45.0s/1m0s + +running (0m46.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 77% ] 1 VUs 0m46.0s/1m0s + +running (0m47.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 78% ] 1 VUs 0m47.0s/1m0s + +running (0m48.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 80% ] 1 VUs 0m48.0s/1m0s + +running (0m49.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 82% ] 1 VUs 0m49.0s/1m0s + +running (0m50.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 83% ] 1 VUs 0m50.0s/1m0s + +running (0m51.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 85% ] 1 VUs 0m51.0s/1m0s + +running (0m52.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 87% ] 1 VUs 0m52.0s/1m0s + +running (0m53.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 88% ] 1 VUs 0m53.0s/1m0s + +running (0m54.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 90% ] 1 VUs 0m54.0s/1m0s + +running (0m55.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 92% ] 1 VUs 0m55.0s/1m0s + +running (0m56.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 93% ] 1 VUs 0m56.0s/1m0s + +running (0m57.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 95% ] 1 VUs 0m57.0s/1m0s + +running (0m58.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 97% ] 1 VUs 0m58.0s/1m0s + +running (0m59.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 98% ] 1 VUs 0m59.0s/1m0s + +running (1m00.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 100% ] 1 VUs 1m00.0s/1m0s + + + â–ˆ THRESHOLDS + + http_req_failed + ✓ 'rate<0.01' rate=0.00% + + + â–ˆ TOTAL RESULTS + + checks_total.......: 24 0.39631/s + checks_succeeded...: 100.00% 24 out of 24 + checks_failed......: 0.00% 0 out of 24 + + ✓ identity profile is 200 + ✓ identity request deletion is 200 + ✓ identity restore is 200 + + HTTP + http_req_duration..............: avg=1.12s min=487.84ms med=1.03s max=2.62s p(90)=1.83s p(95)=1.93s + { expected_response:true }...: avg=1.12s min=487.84ms med=1.03s max=2.62s p(90)=1.83s p(95)=1.93s + http_req_failed................: 0.00% 0 out of 25 + http_reqs......................: 25 0.412823/s + + EXECUTION + iteration_duration.............: avg=7.56s min=6.37s med=7.58s max=9.05s p(90)=8.44s p(95)=8.74s + iterations.....................: 8 0.132103/s + vus............................: 1 min=1 max=1 + vus_max........................: 1 min=1 max=1 + + NETWORK + data_received..................: 22 kB 364 B/s + data_sent......................: 4.5 kB 74 B/s + + + + +running (1m00.6s), 0/1 VUs, 8 complete and 0 interrupted iterations +baseline ✓ [ 100% ] 1 VUs 1m0s + diff --git a/spt/spt_identity_load.log b/spt/spt_identity_load.log new file mode 100644 index 0000000..d1fd675 --- /dev/null +++ b/spt/spt_identity_load.log @@ -0,0 +1,973 @@ + + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + + execution: local + script: spt/identity-spt.js + output: - + + scenarios: (100.00%) 1 scenario, 10 max VUs, 5m30s max duration (incl. graceful stop): + * load: Up to 10 looping VUs for 5m0s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s) + + +running (0m01.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 0% ] 00/10 VUs 0m01.0s/5m00.0s + +running (0m02.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 1% ] 00/10 VUs 0m02.0s/5m00.0s + +running (0m03.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 1% ] 00/10 VUs 0m03.0s/5m00.0s + +running (0m04.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 1% ] 00/10 VUs 0m04.0s/5m00.0s + +running (0m05.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 2% ] 00/10 VUs 0m05.0s/5m00.0s + +running (0m06.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 2% ] 00/10 VUs 0m06.0s/5m00.0s + +running (0m07.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 2% ] 01/10 VUs 0m07.0s/5m00.0s + +running (0m08.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 3% ] 01/10 VUs 0m08.0s/5m00.0s + +running (0m09.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 3% ] 01/10 VUs 0m09.0s/5m00.0s + +running (0m10.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 3% ] 01/10 VUs 0m10.0s/5m00.0s + +running (0m11.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 4% ] 01/10 VUs 0m11.0s/5m00.0s + +running (0m12.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 4% ] 01/10 VUs 0m12.0s/5m00.0s + +running (0m13.0s), 02/10 VUs, 0 complete and 0 interrupted iterations +load [ 4% ] 02/10 VUs 0m13.0s/5m00.0s + +running (0m14.0s), 02/10 VUs, 0 complete and 0 interrupted iterations +load [ 5% ] 02/10 VUs 0m14.0s/5m00.0s + +running (0m15.0s), 02/10 VUs, 0 complete and 0 interrupted iterations +load [ 5% ] 02/10 VUs 0m15.0s/5m00.0s + +running (0m16.0s), 02/10 VUs, 1 complete and 0 interrupted iterations +load [ 5% ] 02/10 VUs 0m16.0s/5m00.0s + +running (0m17.0s), 02/10 VUs, 1 complete and 0 interrupted iterations +load [ 6% ] 02/10 VUs 0m17.0s/5m00.0s + +running (0m18.0s), 02/10 VUs, 1 complete and 0 interrupted iterations +load [ 6% ] 02/10 VUs 0m18.0s/5m00.0s + +running (0m19.0s), 03/10 VUs, 1 complete and 0 interrupted iterations +load [ 6% ] 03/10 VUs 0m19.0s/5m00.0s + +running (0m20.0s), 03/10 VUs, 1 complete and 0 interrupted iterations +load [ 7% ] 03/10 VUs 0m20.0s/5m00.0s + +running (0m21.0s), 03/10 VUs, 1 complete and 0 interrupted iterations +load [ 7% ] 03/10 VUs 0m21.0s/5m00.0s + +running (0m22.0s), 03/10 VUs, 2 complete and 0 interrupted iterations +load [ 7% ] 03/10 VUs 0m22.0s/5m00.0s + +running (0m23.0s), 03/10 VUs, 2 complete and 0 interrupted iterations +load [ 8% ] 03/10 VUs 0m23.0s/5m00.0s + +running (0m24.0s), 03/10 VUs, 3 complete and 0 interrupted iterations +load [ 8% ] 03/10 VUs 0m24.0s/5m00.0s + +running (0m25.0s), 04/10 VUs, 3 complete and 0 interrupted iterations +load [ 8% ] 04/10 VUs 0m25.0s/5m00.0s + +running (0m26.0s), 04/10 VUs, 3 complete and 0 interrupted iterations +load [ 9% ] 04/10 VUs 0m26.0s/5m00.0s + +running (0m27.0s), 04/10 VUs, 3 complete and 0 interrupted iterations +load [ 9% ] 04/10 VUs 0m27.0s/5m00.0s + +running (0m28.0s), 04/10 VUs, 4 complete and 0 interrupted iterations +load [ 9% ] 04/10 VUs 0m28.0s/5m00.0s + +running (0m29.0s), 04/10 VUs, 4 complete and 0 interrupted iterations +load [ 10% ] 04/10 VUs 0m29.0s/5m00.0s + +running (0m30.0s), 04/10 VUs, 5 complete and 0 interrupted iterations +load [ 10% ] 04/10 VUs 0m30.0s/5m00.0s + +running (0m31.0s), 05/10 VUs, 5 complete and 0 interrupted iterations +load [ 10% ] 05/10 VUs 0m31.0s/5m00.0s + +running (0m32.0s), 05/10 VUs, 6 complete and 0 interrupted iterations +load [ 11% ] 05/10 VUs 0m32.0s/5m00.0s + +running (0m33.0s), 05/10 VUs, 7 complete and 0 interrupted iterations +load [ 11% ] 05/10 VUs 0m33.0s/5m00.0s + +running (0m34.0s), 05/10 VUs, 7 complete and 0 interrupted iterations +load [ 11% ] 05/10 VUs 0m34.0s/5m00.0s + +running (0m35.0s), 05/10 VUs, 8 complete and 0 interrupted iterations +load [ 12% ] 05/10 VUs 0m35.0s/5m00.0s + +running (0m36.0s), 05/10 VUs, 8 complete and 0 interrupted iterations +load [ 12% ] 05/10 VUs 0m36.0s/5m00.0s + +running (0m37.0s), 06/10 VUs, 8 complete and 0 interrupted iterations +load [ 12% ] 06/10 VUs 0m37.0s/5m00.0s + +running (0m38.0s), 06/10 VUs, 9 complete and 0 interrupted iterations +load [ 13% ] 06/10 VUs 0m38.0s/5m00.0s + +running (0m39.0s), 06/10 VUs, 11 complete and 0 interrupted iterations +load [ 13% ] 06/10 VUs 0m39.0s/5m00.0s + +running (0m40.0s), 06/10 VUs, 11 complete and 0 interrupted iterations +load [ 13% ] 06/10 VUs 0m40.0s/5m00.0s + +running (0m41.0s), 06/10 VUs, 12 complete and 0 interrupted iterations +load [ 14% ] 06/10 VUs 0m41.0s/5m00.0s + +running (0m42.0s), 06/10 VUs, 12 complete and 0 interrupted iterations +load [ 14% ] 06/10 VUs 0m42.0s/5m00.0s + +running (0m43.0s), 07/10 VUs, 13 complete and 0 interrupted iterations +load [ 14% ] 07/10 VUs 0m43.0s/5m00.0s + +running (0m44.0s), 07/10 VUs, 13 complete and 0 interrupted iterations +load [ 15% ] 07/10 VUs 0m44.0s/5m00.0s + +running (0m45.0s), 07/10 VUs, 15 complete and 0 interrupted iterations +load [ 15% ] 07/10 VUs 0m45.0s/5m00.0s + +running (0m46.0s), 07/10 VUs, 16 complete and 0 interrupted iterations +load [ 15% ] 07/10 VUs 0m46.0s/5m00.0s + +running (0m47.0s), 07/10 VUs, 17 complete and 0 interrupted iterations +load [ 16% ] 07/10 VUs 0m47.0s/5m00.0s + +running (0m48.0s), 07/10 VUs, 17 complete and 0 interrupted iterations +load [ 16% ] 07/10 VUs 0m48.0s/5m00.0s + +running (0m49.0s), 08/10 VUs, 17 complete and 0 interrupted iterations +load [ 16% ] 08/10 VUs 0m49.0s/5m00.0s + +running (0m50.0s), 08/10 VUs, 18 complete and 0 interrupted iterations +load [ 17% ] 08/10 VUs 0m50.0s/5m00.0s + +running (0m51.0s), 08/10 VUs, 18 complete and 0 interrupted iterations +load [ 17% ] 08/10 VUs 0m51.0s/5m00.0s + +running (0m52.0s), 08/10 VUs, 20 complete and 0 interrupted iterations +load [ 17% ] 08/10 VUs 0m52.0s/5m00.0s + +running (0m53.0s), 08/10 VUs, 20 complete and 0 interrupted iterations +load [ 18% ] 08/10 VUs 0m53.0s/5m00.0s + +running (0m54.0s), 08/10 VUs, 23 complete and 0 interrupted iterations +load [ 18% ] 08/10 VUs 0m54.0s/5m00.0s + +running (0m55.0s), 09/10 VUs, 23 complete and 0 interrupted iterations +load [ 18% ] 09/10 VUs 0m55.0s/5m00.0s + +running (0m56.0s), 09/10 VUs, 24 complete and 0 interrupted iterations +load [ 19% ] 09/10 VUs 0m56.0s/5m00.0s + +running (0m57.0s), 09/10 VUs, 24 complete and 0 interrupted iterations +load [ 19% ] 09/10 VUs 0m57.0s/5m00.0s + +running (0m58.0s), 09/10 VUs, 24 complete and 0 interrupted iterations +load [ 19% ] 09/10 VUs 0m58.0s/5m00.0s + +running (0m59.0s), 09/10 VUs, 24 complete and 0 interrupted iterations +load [ 20% ] 09/10 VUs 0m59.0s/5m00.0s + +running (1m00.0s), 09/10 VUs, 27 complete and 0 interrupted iterations +load [ 20% ] 09/10 VUs 1m00.0s/5m00.0s + +running (1m01.0s), 10/10 VUs, 28 complete and 0 interrupted iterations +load [ 20% ] 10/10 VUs 1m01.0s/5m00.0s + +running (1m02.0s), 10/10 VUs, 29 complete and 0 interrupted iterations +load [ 21% ] 10/10 VUs 1m02.0s/5m00.0s + +running (1m03.0s), 10/10 VUs, 31 complete and 0 interrupted iterations +load [ 21% ] 10/10 VUs 1m03.0s/5m00.0s + +running (1m04.0s), 10/10 VUs, 33 complete and 0 interrupted iterations +load [ 21% ] 10/10 VUs 1m04.0s/5m00.0s + +running (1m05.0s), 10/10 VUs, 33 complete and 0 interrupted iterations +load [ 22% ] 10/10 VUs 1m05.0s/5m00.0s + +running (1m06.0s), 10/10 VUs, 33 complete and 0 interrupted iterations +load [ 22% ] 10/10 VUs 1m06.0s/5m00.0s + +running (1m07.0s), 10/10 VUs, 33 complete and 0 interrupted iterations +load [ 22% ] 10/10 VUs 1m07.0s/5m00.0s + +running (1m08.0s), 10/10 VUs, 33 complete and 0 interrupted iterations +load [ 23% ] 10/10 VUs 1m08.0s/5m00.0s + +running (1m09.0s), 10/10 VUs, 34 complete and 0 interrupted iterations +load [ 23% ] 10/10 VUs 1m09.0s/5m00.0s + +running (1m10.0s), 10/10 VUs, 35 complete and 0 interrupted iterations +load [ 23% ] 10/10 VUs 1m10.0s/5m00.0s + +running (1m11.0s), 10/10 VUs, 38 complete and 0 interrupted iterations +load [ 24% ] 10/10 VUs 1m11.0s/5m00.0s + +running (1m12.0s), 10/10 VUs, 40 complete and 0 interrupted iterations +load [ 24% ] 10/10 VUs 1m12.0s/5m00.0s + +running (1m13.0s), 10/10 VUs, 41 complete and 0 interrupted iterations +load [ 24% ] 10/10 VUs 1m13.0s/5m00.0s + +running (1m14.0s), 10/10 VUs, 43 complete and 0 interrupted iterations +load [ 25% ] 10/10 VUs 1m14.0s/5m00.0s + +running (1m15.0s), 10/10 VUs, 43 complete and 0 interrupted iterations +load [ 25% ] 10/10 VUs 1m15.0s/5m00.0s + +running (1m16.0s), 10/10 VUs, 43 complete and 0 interrupted iterations +load [ 25% ] 10/10 VUs 1m16.0s/5m00.0s + +running (1m17.0s), 10/10 VUs, 43 complete and 0 interrupted iterations +load [ 26% ] 10/10 VUs 1m17.0s/5m00.0s + +running (1m18.0s), 10/10 VUs, 43 complete and 0 interrupted iterations +load [ 26% ] 10/10 VUs 1m18.0s/5m00.0s + +running (1m19.0s), 10/10 VUs, 43 complete and 0 interrupted iterations +load [ 26% ] 10/10 VUs 1m19.0s/5m00.0s + +running (1m20.0s), 10/10 VUs, 45 complete and 0 interrupted iterations +load [ 27% ] 10/10 VUs 1m20.0s/5m00.0s + +running (1m21.0s), 10/10 VUs, 45 complete and 0 interrupted iterations +load [ 27% ] 10/10 VUs 1m21.0s/5m00.0s + +running (1m22.0s), 10/10 VUs, 48 complete and 0 interrupted iterations +load [ 27% ] 10/10 VUs 1m22.0s/5m00.0s + +running (1m23.0s), 10/10 VUs, 48 complete and 0 interrupted iterations +load [ 28% ] 10/10 VUs 1m23.0s/5m00.0s + +running (1m24.0s), 10/10 VUs, 53 complete and 0 interrupted iterations +load [ 28% ] 10/10 VUs 1m24.0s/5m00.0s + +running (1m25.0s), 10/10 VUs, 53 complete and 0 interrupted iterations +load [ 28% ] 10/10 VUs 1m25.0s/5m00.0s + +running (1m26.0s), 10/10 VUs, 53 complete and 0 interrupted iterations +load [ 29% ] 10/10 VUs 1m26.0s/5m00.0s + +running (1m27.0s), 10/10 VUs, 53 complete and 0 interrupted iterations +load [ 29% ] 10/10 VUs 1m27.0s/5m00.0s + +running (1m28.0s), 10/10 VUs, 53 complete and 0 interrupted iterations +load [ 29% ] 10/10 VUs 1m28.0s/5m00.0s + +running (1m29.0s), 10/10 VUs, 55 complete and 0 interrupted iterations +load [ 30% ] 10/10 VUs 1m29.0s/5m00.0s + +running (1m30.0s), 10/10 VUs, 55 complete and 0 interrupted iterations +load [ 30% ] 10/10 VUs 1m30.0s/5m00.0s + +running (1m31.0s), 10/10 VUs, 56 complete and 0 interrupted iterations +load [ 30% ] 10/10 VUs 1m31.0s/5m00.0s + +running (1m32.0s), 10/10 VUs, 59 complete and 0 interrupted iterations +load [ 31% ] 10/10 VUs 1m32.0s/5m00.0s + +running (1m33.0s), 10/10 VUs, 62 complete and 0 interrupted iterations +load [ 31% ] 10/10 VUs 1m33.0s/5m00.0s + +running (1m34.0s), 10/10 VUs, 63 complete and 0 interrupted iterations +load [ 31% ] 10/10 VUs 1m34.0s/5m00.0s + +running (1m35.0s), 10/10 VUs, 63 complete and 0 interrupted iterations +load [ 32% ] 10/10 VUs 1m35.0s/5m00.0s + +running (1m36.0s), 10/10 VUs, 64 complete and 0 interrupted iterations +load [ 32% ] 10/10 VUs 1m36.0s/5m00.0s + +running (1m37.0s), 10/10 VUs, 64 complete and 0 interrupted iterations +load [ 32% ] 10/10 VUs 1m37.0s/5m00.0s + +running (1m38.0s), 10/10 VUs, 65 complete and 0 interrupted iterations +load [ 33% ] 10/10 VUs 1m38.0s/5m00.0s + +running (1m39.0s), 10/10 VUs, 65 complete and 0 interrupted iterations +load [ 33% ] 10/10 VUs 1m39.0s/5m00.0s + +running (1m40.0s), 10/10 VUs, 65 complete and 0 interrupted iterations +load [ 33% ] 10/10 VUs 1m40.0s/5m00.0s + +running (1m41.0s), 10/10 VUs, 68 complete and 0 interrupted iterations +load [ 34% ] 10/10 VUs 1m41.0s/5m00.0s + +running (1m42.0s), 10/10 VUs, 70 complete and 0 interrupted iterations +load [ 34% ] 10/10 VUs 1m42.0s/5m00.0s + +running (1m43.0s), 10/10 VUs, 73 complete and 0 interrupted iterations +load [ 34% ] 10/10 VUs 1m43.0s/5m00.0s + +running (1m44.0s), 10/10 VUs, 73 complete and 0 interrupted iterations +load [ 35% ] 10/10 VUs 1m44.0s/5m00.0s + +running (1m45.0s), 10/10 VUs, 74 complete and 0 interrupted iterations +load [ 35% ] 10/10 VUs 1m45.0s/5m00.0s + +running (1m46.0s), 10/10 VUs, 74 complete and 0 interrupted iterations +load [ 35% ] 10/10 VUs 1m46.0s/5m00.0s + +running (1m47.0s), 10/10 VUs, 75 complete and 0 interrupted iterations +load [ 36% ] 10/10 VUs 1m47.0s/5m00.0s + +running (1m48.0s), 10/10 VUs, 75 complete and 0 interrupted iterations +load [ 36% ] 10/10 VUs 1m48.0s/5m00.0s + +running (1m49.0s), 10/10 VUs, 75 complete and 0 interrupted iterations +load [ 36% ] 10/10 VUs 1m49.0s/5m00.0s + +running (1m50.0s), 10/10 VUs, 76 complete and 0 interrupted iterations +load [ 37% ] 10/10 VUs 1m50.0s/5m00.0s + +running (1m51.0s), 10/10 VUs, 77 complete and 0 interrupted iterations +load [ 37% ] 10/10 VUs 1m51.0s/5m00.0s + +running (1m52.0s), 10/10 VUs, 80 complete and 0 interrupted iterations +load [ 37% ] 10/10 VUs 1m52.0s/5m00.0s + +running (1m53.0s), 10/10 VUs, 82 complete and 0 interrupted iterations +load [ 38% ] 10/10 VUs 1m53.0s/5m00.0s + +running (1m54.0s), 10/10 VUs, 83 complete and 0 interrupted iterations +load [ 38% ] 10/10 VUs 1m54.0s/5m00.0s + +running (1m55.0s), 10/10 VUs, 83 complete and 0 interrupted iterations +load [ 38% ] 10/10 VUs 1m55.0s/5m00.0s + +running (1m56.0s), 10/10 VUs, 85 complete and 0 interrupted iterations +load [ 39% ] 10/10 VUs 1m56.0s/5m00.0s + +running (1m57.0s), 10/10 VUs, 85 complete and 0 interrupted iterations +load [ 39% ] 10/10 VUs 1m57.0s/5m00.0s + +running (1m58.0s), 10/10 VUs, 85 complete and 0 interrupted iterations +load [ 39% ] 10/10 VUs 1m58.0s/5m00.0s + +running (1m59.0s), 10/10 VUs, 85 complete and 0 interrupted iterations +load [ 40% ] 10/10 VUs 1m59.0s/5m00.0s + +running (2m00.0s), 10/10 VUs, 86 complete and 0 interrupted iterations +load [ 40% ] 10/10 VUs 2m00.0s/5m00.0s + +running (2m01.0s), 10/10 VUs, 88 complete and 0 interrupted iterations +load [ 40% ] 10/10 VUs 2m01.0s/5m00.0s + +running (2m02.0s), 10/10 VUs, 88 complete and 0 interrupted iterations +load [ 41% ] 10/10 VUs 2m02.0s/5m00.0s + +running (2m03.0s), 10/10 VUs, 92 complete and 0 interrupted iterations +load [ 41% ] 10/10 VUs 2m03.0s/5m00.0s + +running (2m04.0s), 10/10 VUs, 93 complete and 0 interrupted iterations +load [ 41% ] 10/10 VUs 2m04.0s/5m00.0s + +running (2m05.0s), 10/10 VUs, 94 complete and 0 interrupted iterations +load [ 42% ] 10/10 VUs 2m05.0s/5m00.0s + +running (2m06.0s), 10/10 VUs, 94 complete and 0 interrupted iterations +load [ 42% ] 10/10 VUs 2m06.0s/5m00.0s + +running (2m07.0s), 10/10 VUs, 95 complete and 0 interrupted iterations +load [ 42% ] 10/10 VUs 2m07.0s/5m00.0s + +running (2m08.0s), 10/10 VUs, 95 complete and 0 interrupted iterations +load [ 43% ] 10/10 VUs 2m08.0s/5m00.0s + +running (2m09.0s), 10/10 VUs, 95 complete and 0 interrupted iterations +load [ 43% ] 10/10 VUs 2m09.0s/5m00.0s + +running (2m10.0s), 10/10 VUs, 95 complete and 0 interrupted iterations +load [ 43% ] 10/10 VUs 2m10.0s/5m00.0s + +running (2m11.0s), 10/10 VUs, 95 complete and 0 interrupted iterations +load [ 44% ] 10/10 VUs 2m11.0s/5m00.0s + +running (2m12.0s), 10/10 VUs, 97 complete and 0 interrupted iterations +load [ 44% ] 10/10 VUs 2m12.0s/5m00.0s + +running (2m13.0s), 10/10 VUs, 98 complete and 0 interrupted iterations +load [ 44% ] 10/10 VUs 2m13.0s/5m00.0s + +running (2m14.0s), 10/10 VUs, 99 complete and 0 interrupted iterations +load [ 45% ] 10/10 VUs 2m14.0s/5m00.0s + +running (2m15.0s), 10/10 VUs, 102 complete and 0 interrupted iterations +load [ 45% ] 10/10 VUs 2m15.0s/5m00.0s + +running (2m16.0s), 10/10 VUs, 104 complete and 0 interrupted iterations +load [ 45% ] 10/10 VUs 2m16.0s/5m00.0s + +running (2m17.0s), 10/10 VUs, 105 complete and 0 interrupted iterations +load [ 46% ] 10/10 VUs 2m17.0s/5m00.0s + +running (2m18.0s), 10/10 VUs, 105 complete and 0 interrupted iterations +load [ 46% ] 10/10 VUs 2m18.0s/5m00.0s + +running (2m19.0s), 10/10 VUs, 105 complete and 0 interrupted iterations +load [ 46% ] 10/10 VUs 2m19.0s/5m00.0s + +running (2m20.0s), 10/10 VUs, 105 complete and 0 interrupted iterations +load [ 47% ] 10/10 VUs 2m20.0s/5m00.0s + +running (2m21.0s), 10/10 VUs, 106 complete and 0 interrupted iterations +load [ 47% ] 10/10 VUs 2m21.0s/5m00.0s + +running (2m22.0s), 10/10 VUs, 107 complete and 0 interrupted iterations +load [ 47% ] 10/10 VUs 2m22.0s/5m00.0s + +running (2m23.0s), 10/10 VUs, 108 complete and 0 interrupted iterations +load [ 48% ] 10/10 VUs 2m23.0s/5m00.0s + +running (2m24.0s), 10/10 VUs, 112 complete and 0 interrupted iterations +load [ 48% ] 10/10 VUs 2m24.0s/5m00.0s + +running (2m25.0s), 10/10 VUs, 114 complete and 0 interrupted iterations +load [ 48% ] 10/10 VUs 2m25.0s/5m00.0s + +running (2m26.0s), 10/10 VUs, 115 complete and 0 interrupted iterations +load [ 49% ] 10/10 VUs 2m26.0s/5m00.0s + +running (2m27.0s), 10/10 VUs, 115 complete and 0 interrupted iterations +load [ 49% ] 10/10 VUs 2m27.0s/5m00.0s + +running (2m28.0s), 10/10 VUs, 115 complete and 0 interrupted iterations +load [ 49% ] 10/10 VUs 2m28.0s/5m00.0s + +running (2m29.0s), 10/10 VUs, 116 complete and 0 interrupted iterations +load [ 50% ] 10/10 VUs 2m29.0s/5m00.0s + +running (2m30.0s), 10/10 VUs, 116 complete and 0 interrupted iterations +load [ 50% ] 10/10 VUs 2m30.0s/5m00.0s + +running (2m31.0s), 10/10 VUs, 117 complete and 0 interrupted iterations +load [ 50% ] 10/10 VUs 2m31.0s/5m00.0s + +running (2m32.0s), 10/10 VUs, 118 complete and 0 interrupted iterations +load [ 51% ] 10/10 VUs 2m32.0s/5m00.0s + +running (2m33.0s), 10/10 VUs, 119 complete and 0 interrupted iterations +load [ 51% ] 10/10 VUs 2m33.0s/5m00.0s + +running (2m34.0s), 10/10 VUs, 121 complete and 0 interrupted iterations +load [ 51% ] 10/10 VUs 2m34.0s/5m00.0s + +running (2m35.0s), 10/10 VUs, 125 complete and 0 interrupted iterations +load [ 52% ] 10/10 VUs 2m35.0s/5m00.0s + +running (2m36.0s), 10/10 VUs, 125 complete and 0 interrupted iterations +load [ 52% ] 10/10 VUs 2m36.0s/5m00.0s + +running (2m37.0s), 10/10 VUs, 125 complete and 0 interrupted iterations +load [ 52% ] 10/10 VUs 2m37.0s/5m00.0s + +running (2m38.0s), 10/10 VUs, 125 complete and 0 interrupted iterations +load [ 53% ] 10/10 VUs 2m38.0s/5m00.0s + +running (2m39.0s), 10/10 VUs, 126 complete and 0 interrupted iterations +load [ 53% ] 10/10 VUs 2m39.0s/5m00.0s + +running (2m40.0s), 10/10 VUs, 127 complete and 0 interrupted iterations +load [ 53% ] 10/10 VUs 2m40.0s/5m00.0s + +running (2m41.0s), 10/10 VUs, 127 complete and 0 interrupted iterations +load [ 54% ] 10/10 VUs 2m41.0s/5m00.0s + +running (2m42.0s), 10/10 VUs, 128 complete and 0 interrupted iterations +load [ 54% ] 10/10 VUs 2m42.0s/5m00.0s + +running (2m43.0s), 10/10 VUs, 129 complete and 0 interrupted iterations +load [ 54% ] 10/10 VUs 2m43.0s/5m00.0s + +running (2m44.0s), 10/10 VUs, 132 complete and 0 interrupted iterations +load [ 55% ] 10/10 VUs 2m44.0s/5m00.0s + +running (2m45.0s), 10/10 VUs, 134 complete and 0 interrupted iterations +load [ 55% ] 10/10 VUs 2m45.0s/5m00.0s + +running (2m46.0s), 10/10 VUs, 135 complete and 0 interrupted iterations +load [ 55% ] 10/10 VUs 2m46.0s/5m00.0s + +running (2m47.0s), 10/10 VUs, 135 complete and 0 interrupted iterations +load [ 56% ] 10/10 VUs 2m47.0s/5m00.0s + +running (2m48.0s), 10/10 VUs, 136 complete and 0 interrupted iterations +load [ 56% ] 10/10 VUs 2m48.0s/5m00.0s + +running (2m49.0s), 10/10 VUs, 137 complete and 0 interrupted iterations +load [ 56% ] 10/10 VUs 2m49.0s/5m00.0s + +running (2m50.0s), 10/10 VUs, 137 complete and 0 interrupted iterations +load [ 57% ] 10/10 VUs 2m50.0s/5m00.0s + +running (2m51.0s), 10/10 VUs, 139 complete and 0 interrupted iterations +load [ 57% ] 10/10 VUs 2m51.0s/5m00.0s + +running (2m52.0s), 10/10 VUs, 142 complete and 0 interrupted iterations +load [ 57% ] 10/10 VUs 2m52.0s/5m00.0s + +running (2m53.0s), 10/10 VUs, 143 complete and 0 interrupted iterations +load [ 58% ] 10/10 VUs 2m53.0s/5m00.0s + +running (2m54.0s), 10/10 VUs, 145 complete and 0 interrupted iterations +load [ 58% ] 10/10 VUs 2m54.0s/5m00.0s + +running (2m55.0s), 10/10 VUs, 145 complete and 0 interrupted iterations +load [ 58% ] 10/10 VUs 2m55.0s/5m00.0s + +running (2m56.0s), 10/10 VUs, 146 complete and 0 interrupted iterations +load [ 59% ] 10/10 VUs 2m56.0s/5m00.0s + +running (2m57.0s), 10/10 VUs, 147 complete and 0 interrupted iterations +load [ 59% ] 10/10 VUs 2m57.0s/5m00.0s + +running (2m58.0s), 10/10 VUs, 147 complete and 0 interrupted iterations +load [ 59% ] 10/10 VUs 2m58.0s/5m00.0s + +running (2m59.0s), 10/10 VUs, 148 complete and 0 interrupted iterations +load [ 60% ] 10/10 VUs 2m59.0s/5m00.0s + +running (3m00.0s), 10/10 VUs, 149 complete and 0 interrupted iterations +load [ 60% ] 10/10 VUs 3m00.0s/5m00.0s + +running (3m01.0s), 10/10 VUs, 149 complete and 0 interrupted iterations +load [ 60% ] 10/10 VUs 3m01.0s/5m00.0s + +running (3m02.0s), 10/10 VUs, 151 complete and 0 interrupted iterations +load [ 61% ] 10/10 VUs 3m02.0s/5m00.0s + +running (3m03.0s), 10/10 VUs, 153 complete and 0 interrupted iterations +load [ 61% ] 10/10 VUs 3m03.0s/5m00.0s + +running (3m04.0s), 10/10 VUs, 156 complete and 0 interrupted iterations +load [ 61% ] 10/10 VUs 3m04.0s/5m00.0s + +running (3m05.0s), 10/10 VUs, 156 complete and 0 interrupted iterations +load [ 62% ] 10/10 VUs 3m05.0s/5m00.0s + +running (3m06.0s), 10/10 VUs, 157 complete and 0 interrupted iterations +load [ 62% ] 10/10 VUs 3m06.0s/5m00.0s + +running (3m07.0s), 10/10 VUs, 157 complete and 0 interrupted iterations +load [ 62% ] 10/10 VUs 3m07.0s/5m00.0s + +running (3m08.0s), 10/10 VUs, 158 complete and 0 interrupted iterations +load [ 63% ] 10/10 VUs 3m08.0s/5m00.0s + +running (3m09.0s), 10/10 VUs, 158 complete and 0 interrupted iterations +load [ 63% ] 10/10 VUs 3m09.0s/5m00.0s + +running (3m10.0s), 10/10 VUs, 159 complete and 0 interrupted iterations +load [ 63% ] 10/10 VUs 3m10.0s/5m00.0s + +running (3m11.0s), 10/10 VUs, 159 complete and 0 interrupted iterations +load [ 64% ] 10/10 VUs 3m11.0s/5m00.0s + +running (3m12.0s), 10/10 VUs, 160 complete and 0 interrupted iterations +load [ 64% ] 10/10 VUs 3m12.0s/5m00.0s + +running (3m13.0s), 10/10 VUs, 163 complete and 0 interrupted iterations +load [ 64% ] 10/10 VUs 3m13.0s/5m00.0s + +running (3m14.0s), 10/10 VUs, 164 complete and 0 interrupted iterations +load [ 65% ] 10/10 VUs 3m14.0s/5m00.0s + +running (3m15.0s), 10/10 VUs, 166 complete and 0 interrupted iterations +load [ 65% ] 10/10 VUs 3m15.0s/5m00.0s + +running (3m16.0s), 10/10 VUs, 167 complete and 0 interrupted iterations +load [ 65% ] 10/10 VUs 3m16.0s/5m00.0s + +running (3m17.0s), 10/10 VUs, 168 complete and 0 interrupted iterations +load [ 66% ] 10/10 VUs 3m17.0s/5m00.0s + +running (3m18.0s), 10/10 VUs, 168 complete and 0 interrupted iterations +load [ 66% ] 10/10 VUs 3m18.0s/5m00.0s + +running (3m19.0s), 10/10 VUs, 168 complete and 0 interrupted iterations +load [ 66% ] 10/10 VUs 3m19.0s/5m00.0s + +running (3m20.0s), 10/10 VUs, 168 complete and 0 interrupted iterations +load [ 67% ] 10/10 VUs 3m20.0s/5m00.0s + +running (3m21.0s), 10/10 VUs, 169 complete and 0 interrupted iterations +load [ 67% ] 10/10 VUs 3m21.0s/5m00.0s + +running (3m22.0s), 10/10 VUs, 169 complete and 0 interrupted iterations +load [ 67% ] 10/10 VUs 3m22.0s/5m00.0s + +running (3m23.0s), 10/10 VUs, 171 complete and 0 interrupted iterations +load [ 68% ] 10/10 VUs 3m23.0s/5m00.0s + +running (3m24.0s), 10/10 VUs, 173 complete and 0 interrupted iterations +load [ 68% ] 10/10 VUs 3m24.0s/5m00.0s + +running (3m25.0s), 10/10 VUs, 175 complete and 0 interrupted iterations +load [ 68% ] 10/10 VUs 3m25.0s/5m00.0s + +running (3m26.0s), 10/10 VUs, 178 complete and 0 interrupted iterations +load [ 69% ] 10/10 VUs 3m26.0s/5m00.0s + +running (3m27.0s), 10/10 VUs, 178 complete and 0 interrupted iterations +load [ 69% ] 10/10 VUs 3m27.0s/5m00.0s + +running (3m28.0s), 10/10 VUs, 178 complete and 0 interrupted iterations +load [ 69% ] 10/10 VUs 3m28.0s/5m00.0s + +running (3m29.0s), 10/10 VUs, 178 complete and 0 interrupted iterations +load [ 70% ] 10/10 VUs 3m29.0s/5m00.0s + +running (3m30.0s), 10/10 VUs, 178 complete and 0 interrupted iterations +load [ 70% ] 10/10 VUs 3m30.0s/5m00.0s + +running (3m31.0s), 10/10 VUs, 179 complete and 0 interrupted iterations +load [ 70% ] 10/10 VUs 3m31.0s/5m00.0s + +running (3m32.0s), 10/10 VUs, 180 complete and 0 interrupted iterations +load [ 71% ] 10/10 VUs 3m32.0s/5m00.0s + +running (3m33.0s), 10/10 VUs, 181 complete and 0 interrupted iterations +load [ 71% ] 10/10 VUs 3m33.0s/5m00.0s + +running (3m34.0s), 10/10 VUs, 181 complete and 0 interrupted iterations +load [ 71% ] 10/10 VUs 3m34.0s/5m00.0s + +running (3m35.0s), 10/10 VUs, 184 complete and 0 interrupted iterations +load [ 72% ] 10/10 VUs 3m35.0s/5m00.0s + +running (3m36.0s), 10/10 VUs, 187 complete and 0 interrupted iterations +load [ 72% ] 10/10 VUs 3m36.0s/5m00.0s + +running (3m37.0s), 10/10 VUs, 188 complete and 0 interrupted iterations +load [ 72% ] 10/10 VUs 3m37.0s/5m00.0s + +running (3m38.0s), 10/10 VUs, 188 complete and 0 interrupted iterations +load [ 73% ] 10/10 VUs 3m38.0s/5m00.0s + +running (3m39.0s), 10/10 VUs, 188 complete and 0 interrupted iterations +load [ 73% ] 10/10 VUs 3m39.0s/5m00.0s + +running (3m40.0s), 10/10 VUs, 189 complete and 0 interrupted iterations +load [ 73% ] 10/10 VUs 3m40.0s/5m00.0s + +running (3m41.0s), 10/10 VUs, 189 complete and 0 interrupted iterations +load [ 74% ] 10/10 VUs 3m41.0s/5m00.0s + +running (3m42.0s), 10/10 VUs, 190 complete and 0 interrupted iterations +load [ 74% ] 10/10 VUs 3m42.0s/5m00.0s + +running (3m43.0s), 10/10 VUs, 191 complete and 0 interrupted iterations +load [ 74% ] 10/10 VUs 3m43.0s/5m00.0s + +running (3m44.0s), 10/10 VUs, 191 complete and 0 interrupted iterations +load [ 75% ] 10/10 VUs 3m44.0s/5m00.0s + +running (3m45.0s), 10/10 VUs, 192 complete and 0 interrupted iterations +load [ 75% ] 10/10 VUs 3m45.0s/5m00.0s + +running (3m46.0s), 10/10 VUs, 194 complete and 0 interrupted iterations +load [ 75% ] 10/10 VUs 3m46.0s/5m00.0s + +running (3m47.0s), 10/10 VUs, 196 complete and 0 interrupted iterations +load [ 76% ] 10/10 VUs 3m47.0s/5m00.0s + +running (3m48.0s), 10/10 VUs, 198 complete and 0 interrupted iterations +load [ 76% ] 10/10 VUs 3m48.0s/5m00.0s + +running (3m49.0s), 10/10 VUs, 198 complete and 0 interrupted iterations +load [ 76% ] 10/10 VUs 3m49.0s/5m00.0s + +running (3m50.0s), 10/10 VUs, 199 complete and 0 interrupted iterations +load [ 77% ] 10/10 VUs 3m50.0s/5m00.0s + +running (3m51.0s), 10/10 VUs, 200 complete and 0 interrupted iterations +load [ 77% ] 10/10 VUs 3m51.0s/5m00.0s + +running (3m52.0s), 10/10 VUs, 201 complete and 0 interrupted iterations +load [ 77% ] 10/10 VUs 3m52.0s/5m00.0s + +running (3m53.0s), 10/10 VUs, 201 complete and 0 interrupted iterations +load [ 78% ] 10/10 VUs 3m53.0s/5m00.0s + +running (3m54.0s), 10/10 VUs, 203 complete and 0 interrupted iterations +load [ 78% ] 10/10 VUs 3m54.0s/5m00.0s + +running (3m55.0s), 10/10 VUs, 205 complete and 0 interrupted iterations +load [ 78% ] 10/10 VUs 3m55.0s/5m00.0s + +running (3m56.0s), 10/10 VUs, 207 complete and 0 interrupted iterations +load [ 79% ] 10/10 VUs 3m56.0s/5m00.0s + +running (3m57.0s), 10/10 VUs, 208 complete and 0 interrupted iterations +load [ 79% ] 10/10 VUs 3m57.0s/5m00.0s + +running (3m58.0s), 10/10 VUs, 209 complete and 0 interrupted iterations +load [ 79% ] 10/10 VUs 3m58.0s/5m00.0s + +running (3m59.0s), 10/10 VUs, 209 complete and 0 interrupted iterations +load [ 80% ] 10/10 VUs 3m59.0s/5m00.0s + +running (4m00.0s), 10/10 VUs, 211 complete and 0 interrupted iterations +load [ 80% ] 10/10 VUs 4m00.0s/5m00.0s + +running (4m01.0s), 10/10 VUs, 211 complete and 0 interrupted iterations +load [ 80% ] 10/10 VUs 4m01.0s/5m00.0s + +running (4m02.0s), 10/10 VUs, 211 complete and 0 interrupted iterations +load [ 81% ] 10/10 VUs 4m02.0s/5m00.0s + +running (4m03.0s), 10/10 VUs, 213 complete and 0 interrupted iterations +load [ 81% ] 10/10 VUs 4m03.0s/5m00.0s + +running (4m04.0s), 10/10 VUs, 216 complete and 0 interrupted iterations +load [ 81% ] 10/10 VUs 4m04.0s/5m00.0s + +running (4m05.0s), 10/10 VUs, 218 complete and 0 interrupted iterations +load [ 82% ] 10/10 VUs 4m05.0s/5m00.0s + +running (4m06.0s), 10/10 VUs, 219 complete and 0 interrupted iterations +load [ 82% ] 10/10 VUs 4m06.0s/5m00.0s + +running (4m07.0s), 10/10 VUs, 219 complete and 0 interrupted iterations +load [ 82% ] 10/10 VUs 4m07.0s/5m00.0s + +running (4m08.0s), 10/10 VUs, 219 complete and 0 interrupted iterations +load [ 83% ] 10/10 VUs 4m08.0s/5m00.0s + +running (4m09.0s), 10/10 VUs, 219 complete and 0 interrupted iterations +load [ 83% ] 10/10 VUs 4m09.0s/5m00.0s + +running (4m10.0s), 10/10 VUs, 221 complete and 0 interrupted iterations +load [ 83% ] 10/10 VUs 4m10.0s/5m00.0s + +running (4m11.0s), 10/10 VUs, 221 complete and 0 interrupted iterations +load [ 84% ] 10/10 VUs 4m11.0s/5m00.0s + +running (4m12.0s), 10/10 VUs, 222 complete and 0 interrupted iterations +load [ 84% ] 10/10 VUs 4m12.0s/5m00.0s + +running (4m13.0s), 10/10 VUs, 222 complete and 0 interrupted iterations +load [ 84% ] 10/10 VUs 4m13.0s/5m00.0s + +running (4m14.0s), 10/10 VUs, 223 complete and 0 interrupted iterations +load [ 85% ] 10/10 VUs 4m14.0s/5m00.0s + +running (4m15.0s), 09/10 VUs, 225 complete and 0 interrupted iterations +load [ 85% ] 09/10 VUs 4m15.0s/5m00.0s + +running (4m16.0s), 08/10 VUs, 227 complete and 0 interrupted iterations +load [ 85% ] 08/10 VUs 4m16.0s/5m00.0s + +running (4m17.0s), 08/10 VUs, 229 complete and 0 interrupted iterations +load [ 86% ] 08/10 VUs 4m17.0s/5m00.0s + +running (4m18.0s), 08/10 VUs, 229 complete and 0 interrupted iterations +load [ 86% ] 08/10 VUs 4m18.0s/5m00.0s + +running (4m19.0s), 08/10 VUs, 231 complete and 0 interrupted iterations +load [ 86% ] 08/10 VUs 4m19.0s/5m00.0s + +running (4m20.0s), 08/10 VUs, 231 complete and 0 interrupted iterations +load [ 87% ] 08/10 VUs 4m20.0s/5m00.0s + +running (4m21.0s), 08/10 VUs, 231 complete and 0 interrupted iterations +load [ 87% ] 08/10 VUs 4m21.0s/5m00.0s + +running (4m22.0s), 08/10 VUs, 232 complete and 0 interrupted iterations +load [ 87% ] 08/10 VUs 4m22.0s/5m00.0s + +running (4m23.0s), 08/10 VUs, 232 complete and 0 interrupted iterations +load [ 88% ] 08/10 VUs 4m23.0s/5m00.0s + +running (4m24.0s), 08/10 VUs, 234 complete and 0 interrupted iterations +load [ 88% ] 08/10 VUs 4m24.0s/5m00.0s + +running (4m25.0s), 07/10 VUs, 236 complete and 0 interrupted iterations +load [ 88% ] 07/10 VUs 4m25.0s/5m00.0s + +running (4m26.0s), 07/10 VUs, 238 complete and 0 interrupted iterations +load [ 89% ] 07/10 VUs 4m26.0s/5m00.0s + +running (4m27.0s), 07/10 VUs, 238 complete and 0 interrupted iterations +load [ 89% ] 07/10 VUs 4m27.0s/5m00.0s + +running (4m28.0s), 07/10 VUs, 239 complete and 0 interrupted iterations +load [ 89% ] 07/10 VUs 4m28.0s/5m00.0s + +running (4m29.0s), 07/10 VUs, 239 complete and 0 interrupted iterations +load [ 90% ] 07/10 VUs 4m29.0s/5m00.0s + +running (4m30.0s), 07/10 VUs, 239 complete and 0 interrupted iterations +load [ 90% ] 07/10 VUs 4m30.0s/5m00.0s + +running (4m31.0s), 07/10 VUs, 240 complete and 0 interrupted iterations +load [ 90% ] 07/10 VUs 4m31.0s/5m00.0s + +running (4m32.0s), 07/10 VUs, 241 complete and 0 interrupted iterations +load [ 91% ] 07/10 VUs 4m32.0s/5m00.0s + +running (4m33.0s), 06/10 VUs, 243 complete and 0 interrupted iterations +load [ 91% ] 06/10 VUs 4m33.0s/5m00.0s + +running (4m34.0s), 06/10 VUs, 244 complete and 0 interrupted iterations +load [ 91% ] 06/10 VUs 4m34.0s/5m00.0s + +running (4m35.0s), 06/10 VUs, 245 complete and 0 interrupted iterations +load [ 92% ] 06/10 VUs 4m35.0s/5m00.0s + +running (4m36.0s), 05/10 VUs, 246 complete and 0 interrupted iterations +load [ 92% ] 05/10 VUs 4m36.0s/5m00.0s + +running (4m37.0s), 05/10 VUs, 246 complete and 0 interrupted iterations +load [ 92% ] 05/10 VUs 4m37.0s/5m00.0s + +running (4m38.0s), 05/10 VUs, 247 complete and 0 interrupted iterations +load [ 93% ] 05/10 VUs 4m38.0s/5m00.0s + +running (4m39.0s), 05/10 VUs, 247 complete and 0 interrupted iterations +load [ 93% ] 05/10 VUs 4m39.0s/5m00.0s + +running (4m40.0s), 05/10 VUs, 248 complete and 0 interrupted iterations +load [ 93% ] 05/10 VUs 4m40.0s/5m00.0s + +running (4m41.0s), 05/10 VUs, 250 complete and 0 interrupted iterations +load [ 94% ] 05/10 VUs 4m41.0s/5m00.0s + +running (4m42.0s), 05/10 VUs, 250 complete and 0 interrupted iterations +load [ 94% ] 05/10 VUs 4m42.0s/5m00.0s + +running (4m43.0s), 04/10 VUs, 251 complete and 0 interrupted iterations +load [ 94% ] 04/10 VUs 4m43.0s/5m00.0s + +running (4m44.0s), 04/10 VUs, 251 complete and 0 interrupted iterations +load [ 95% ] 04/10 VUs 4m44.0s/5m00.0s + +running (4m45.0s), 04/10 VUs, 252 complete and 0 interrupted iterations +load [ 95% ] 04/10 VUs 4m45.0s/5m00.0s + +running (4m46.0s), 04/10 VUs, 252 complete and 0 interrupted iterations +load [ 95% ] 04/10 VUs 4m46.0s/5m00.0s + +running (4m47.0s), 04/10 VUs, 252 complete and 0 interrupted iterations +load [ 96% ] 04/10 VUs 4m47.0s/5m00.0s + +running (4m48.0s), 04/10 VUs, 252 complete and 0 interrupted iterations +load [ 96% ] 04/10 VUs 4m48.0s/5m00.0s + +running (4m49.0s), 02/10 VUs, 255 complete and 0 interrupted iterations +load [ 96% ] 02/10 VUs 4m49.0s/5m00.0s + +running (4m50.0s), 02/10 VUs, 255 complete and 0 interrupted iterations +load [ 97% ] 02/10 VUs 4m50.0s/5m00.0s + +running (4m51.0s), 02/10 VUs, 255 complete and 0 interrupted iterations +load [ 97% ] 02/10 VUs 4m51.0s/5m00.0s + +running (4m52.0s), 02/10 VUs, 256 complete and 0 interrupted iterations +load [ 97% ] 02/10 VUs 4m52.0s/5m00.0s + +running (4m53.0s), 02/10 VUs, 256 complete and 0 interrupted iterations +load [ 98% ] 02/10 VUs 4m53.0s/5m00.0s + +running (4m54.0s), 02/10 VUs, 256 complete and 0 interrupted iterations +load [ 98% ] 02/10 VUs 4m54.0s/5m00.0s + +running (4m55.0s), 01/10 VUs, 257 complete and 0 interrupted iterations +load [ 98% ] 01/10 VUs 4m55.0s/5m00.0s + +running (4m56.0s), 01/10 VUs, 257 complete and 0 interrupted iterations +load [ 99% ] 01/10 VUs 4m56.0s/5m00.0s + +running (4m57.0s), 01/10 VUs, 257 complete and 0 interrupted iterations +load [ 99% ] 01/10 VUs 4m57.0s/5m00.0s + +running (4m58.0s), 01/10 VUs, 257 complete and 0 interrupted iterations +load [ 99% ] 01/10 VUs 4m58.0s/5m00.0s + +running (4m59.0s), 01/10 VUs, 257 complete and 0 interrupted iterations +load [ 100% ] 01/10 VUs 4m59.0s/5m00.0s + +running (5m00.0s), 01/10 VUs, 258 complete and 0 interrupted iterations +load [ 100% ] 01/10 VUs 5m00.0s/5m00.0s + +running (5m01.0s), 01/10 VUs, 258 complete and 0 interrupted iterations +load ↓ [ 100% ] 01/10 VUs 5m0s + +running (5m02.0s), 01/10 VUs, 258 complete and 0 interrupted iterations +load ↓ [ 100% ] 01/10 VUs 5m0s + +running (5m03.0s), 01/10 VUs, 258 complete and 0 interrupted iterations +load ↓ [ 100% ] 01/10 VUs 5m0s + +running (5m04.0s), 01/10 VUs, 258 complete and 0 interrupted iterations +load ↓ [ 100% ] 01/10 VUs 5m0s + +running (5m05.0s), 01/10 VUs, 258 complete and 0 interrupted iterations +load ↓ [ 100% ] 01/10 VUs 5m0s + +running (5m06.0s), 01/10 VUs, 258 complete and 0 interrupted iterations +load ↓ [ 100% ] 01/10 VUs 5m0s + + + â–ˆ THRESHOLDS + + http_req_failed + ✓ 'rate<0.01' rate=0.00% + + + â–ˆ TOTAL RESULTS + + checks_total.......: 777 2.533646/s + checks_succeeded...: 100.00% 777 out of 777 + checks_failed......: 0.00% 0 out of 777 + + ✓ identity profile is 200 + ✓ identity request deletion is 200 + ✓ identity restore is 200 + + HTTP + http_req_duration..............: avg=1.79s min=439.79ms med=1.61s max=5.07s p(90)=3.06s p(95)=3.58s + { expected_response:true }...: avg=1.79s min=439.79ms med=1.61s max=5.07s p(90)=3.06s p(95)=3.58s + http_req_failed................: 0.00% 0 out of 787 + http_reqs......................: 787 2.566254/s + + EXECUTION + iteration_duration.............: avg=9.47s min=6.78s med=9.49s max=12.72s p(90)=10.91s p(95)=11.46s + iterations.....................: 259 0.844549/s + vus............................: 1 min=0 max=10 + vus_max........................: 10 min=10 max=10 + + NETWORK + data_received..................: 535 kB 1.7 kB/s + data_sent......................: 78 kB 254 B/s + + + + +running (5m06.7s), 00/10 VUs, 259 complete and 0 interrupted iterations +load ✓ [ 100% ] 00/10 VUs 5m0s + diff --git a/spt/spt_identity_stress.log b/spt/spt_identity_stress.log new file mode 100644 index 0000000..7b62c78 --- /dev/null +++ b/spt/spt_identity_stress.log @@ -0,0 +1,788 @@ + + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + + execution: local + script: spt/identity-spt.js + output: - + + scenarios: (100.00%) 1 scenario, 50 max VUs, 4m30s max duration (incl. graceful stop): + * stress: Up to 50 looping VUs for 4m0s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s) + + +running (0m01.0s), 00/50 VUs, 0 complete and 0 interrupted iterations +stress [ 0% ] 00/50 VUs 0m01.0s/4m00.0s + +running (0m02.0s), 01/50 VUs, 0 complete and 0 interrupted iterations +stress [ 1% ] 01/50 VUs 0m02.0s/4m00.0s + +running (0m03.0s), 02/50 VUs, 0 complete and 0 interrupted iterations +stress [ 1% ] 02/50 VUs 0m03.0s/4m00.0s + +running (0m04.0s), 03/50 VUs, 0 complete and 0 interrupted iterations +stress [ 2% ] 03/50 VUs 0m04.0s/4m00.0s + +running (0m05.0s), 04/50 VUs, 0 complete and 0 interrupted iterations +stress [ 2% ] 04/50 VUs 0m05.0s/4m00.0s + +running (0m06.0s), 04/50 VUs, 0 complete and 0 interrupted iterations +stress [ 2% ] 04/50 VUs 0m06.0s/4m00.0s + +running (0m07.0s), 05/50 VUs, 0 complete and 0 interrupted iterations +stress [ 3% ] 05/50 VUs 0m07.0s/4m00.0s + +running (0m08.0s), 06/50 VUs, 0 complete and 0 interrupted iterations +stress [ 3% ] 06/50 VUs 0m08.0s/4m00.0s + +running (0m09.0s), 07/50 VUs, 0 complete and 0 interrupted iterations +stress [ 4% ] 07/50 VUs 0m09.0s/4m00.0s + +running (0m10.0s), 08/50 VUs, 0 complete and 0 interrupted iterations +stress [ 4% ] 08/50 VUs 0m10.0s/4m00.0s + +running (0m11.0s), 09/50 VUs, 0 complete and 0 interrupted iterations +stress [ 5% ] 09/50 VUs 0m11.0s/4m00.0s + +running (0m12.0s), 09/50 VUs, 0 complete and 0 interrupted iterations +stress [ 5% ] 09/50 VUs 0m12.0s/4m00.0s + +running (0m13.0s), 10/50 VUs, 1 complete and 0 interrupted iterations +stress [ 5% ] 10/50 VUs 0m13.0s/4m00.0s + +running (0m14.0s), 11/50 VUs, 1 complete and 0 interrupted iterations +stress [ 6% ] 11/50 VUs 0m14.0s/4m00.0s + +running (0m15.0s), 12/50 VUs, 2 complete and 0 interrupted iterations +stress [ 6% ] 12/50 VUs 0m15.0s/4m00.0s + +running (0m16.0s), 13/50 VUs, 4 complete and 0 interrupted iterations +stress [ 7% ] 13/50 VUs 0m16.0s/4m00.0s + +running (0m17.0s), 14/50 VUs, 4 complete and 0 interrupted iterations +stress [ 7% ] 14/50 VUs 0m17.0s/4m00.0s + +running (0m18.0s), 14/50 VUs, 4 complete and 0 interrupted iterations +stress [ 7% ] 14/50 VUs 0m18.0s/4m00.0s + +running (0m19.0s), 15/50 VUs, 5 complete and 0 interrupted iterations +stress [ 8% ] 15/50 VUs 0m19.0s/4m00.0s + +running (0m20.0s), 16/50 VUs, 5 complete and 0 interrupted iterations +stress [ 8% ] 16/50 VUs 0m20.0s/4m00.0s + +running (0m21.0s), 17/50 VUs, 6 complete and 0 interrupted iterations +stress [ 9% ] 17/50 VUs 0m21.0s/4m00.0s + +running (0m22.0s), 18/50 VUs, 7 complete and 0 interrupted iterations +stress [ 9% ] 18/50 VUs 0m22.0s/4m00.0s + +running (0m23.0s), 19/50 VUs, 7 complete and 0 interrupted iterations +stress [ 10% ] 19/50 VUs 0m23.0s/4m00.0s + +running (0m24.0s), 19/50 VUs, 7 complete and 0 interrupted iterations +stress [ 10% ] 19/50 VUs 0m24.0s/4m00.0s + +running (0m25.0s), 20/50 VUs, 8 complete and 0 interrupted iterations +stress [ 10% ] 20/50 VUs 0m25.0s/4m00.0s + +running (0m26.0s), 21/50 VUs, 8 complete and 0 interrupted iterations +stress [ 11% ] 21/50 VUs 0m26.0s/4m00.0s + +running (0m27.0s), 22/50 VUs, 8 complete and 0 interrupted iterations +stress [ 11% ] 22/50 VUs 0m27.0s/4m00.0s + +running (0m28.0s), 23/50 VUs, 10 complete and 0 interrupted iterations +stress [ 12% ] 23/50 VUs 0m28.0s/4m00.0s + +running (0m29.0s), 24/50 VUs, 10 complete and 0 interrupted iterations +stress [ 12% ] 24/50 VUs 0m29.0s/4m00.0s + +running (0m30.0s), 24/50 VUs, 11 complete and 0 interrupted iterations +stress [ 12% ] 24/50 VUs 0m30.0s/4m00.0s + +running (0m31.0s), 25/50 VUs, 13 complete and 0 interrupted iterations +stress [ 13% ] 25/50 VUs 0m31.0s/4m00.0s + +running (0m32.0s), 26/50 VUs, 13 complete and 0 interrupted iterations +stress [ 13% ] 26/50 VUs 0m32.0s/4m00.0s + +running (0m33.0s), 27/50 VUs, 15 complete and 0 interrupted iterations +stress [ 14% ] 27/50 VUs 0m33.0s/4m00.0s + +running (0m34.0s), 28/50 VUs, 15 complete and 0 interrupted iterations +stress [ 14% ] 28/50 VUs 0m34.0s/4m00.0s + +running (0m35.0s), 29/50 VUs, 17 complete and 0 interrupted iterations +stress [ 15% ] 29/50 VUs 0m35.0s/4m00.0s + +running (0m36.0s), 29/50 VUs, 17 complete and 0 interrupted iterations +stress [ 15% ] 29/50 VUs 0m36.0s/4m00.0s + +running (0m37.0s), 30/50 VUs, 17 complete and 0 interrupted iterations +stress [ 15% ] 30/50 VUs 0m37.0s/4m00.0s + +running (0m38.0s), 31/50 VUs, 19 complete and 0 interrupted iterations +stress [ 16% ] 31/50 VUs 0m38.0s/4m00.0s + +running (0m39.0s), 32/50 VUs, 20 complete and 0 interrupted iterations +stress [ 16% ] 32/50 VUs 0m39.0s/4m00.0s + +running (0m40.0s), 33/50 VUs, 20 complete and 0 interrupted iterations +stress [ 17% ] 33/50 VUs 0m40.0s/4m00.0s + +running (0m41.0s), 34/50 VUs, 20 complete and 0 interrupted iterations +stress [ 17% ] 34/50 VUs 0m41.0s/4m00.0s + +running (0m42.0s), 34/50 VUs, 21 complete and 0 interrupted iterations +stress [ 17% ] 34/50 VUs 0m42.0s/4m00.0s + +running (0m43.0s), 35/50 VUs, 22 complete and 0 interrupted iterations +stress [ 18% ] 35/50 VUs 0m43.0s/4m00.0s + +running (0m44.0s), 36/50 VUs, 24 complete and 0 interrupted iterations +stress [ 18% ] 36/50 VUs 0m44.0s/4m00.0s + +running (0m45.0s), 37/50 VUs, 25 complete and 0 interrupted iterations +stress [ 19% ] 37/50 VUs 0m45.0s/4m00.0s + +running (0m46.0s), 38/50 VUs, 26 complete and 0 interrupted iterations +stress [ 19% ] 38/50 VUs 0m46.0s/4m00.0s + +running (0m47.0s), 39/50 VUs, 27 complete and 0 interrupted iterations +stress [ 20% ] 39/50 VUs 0m47.0s/4m00.0s + +running (0m48.0s), 39/50 VUs, 27 complete and 0 interrupted iterations +stress [ 20% ] 39/50 VUs 0m48.0s/4m00.0s + +running (0m49.0s), 40/50 VUs, 27 complete and 0 interrupted iterations +stress [ 20% ] 40/50 VUs 0m49.0s/4m00.0s + +running (0m50.0s), 41/50 VUs, 27 complete and 0 interrupted iterations +stress [ 21% ] 41/50 VUs 0m50.0s/4m00.0s + +running (0m51.0s), 42/50 VUs, 27 complete and 0 interrupted iterations +stress [ 21% ] 42/50 VUs 0m51.0s/4m00.0s + +running (0m52.0s), 43/50 VUs, 27 complete and 0 interrupted iterations +stress [ 22% ] 43/50 VUs 0m52.0s/4m00.0s + +running (0m53.0s), 44/50 VUs, 28 complete and 0 interrupted iterations +stress [ 22% ] 44/50 VUs 0m53.0s/4m00.0s + +running (0m54.0s), 44/50 VUs, 30 complete and 0 interrupted iterations +stress [ 22% ] 44/50 VUs 0m54.0s/4m00.0s + +running (0m55.0s), 45/50 VUs, 32 complete and 0 interrupted iterations +stress [ 23% ] 45/50 VUs 0m55.0s/4m00.0s + +running (0m56.0s), 46/50 VUs, 32 complete and 0 interrupted iterations +stress [ 23% ] 46/50 VUs 0m56.0s/4m00.0s + +running (0m57.0s), 47/50 VUs, 33 complete and 0 interrupted iterations +stress [ 24% ] 47/50 VUs 0m57.0s/4m00.0s + +running (0m58.0s), 48/50 VUs, 35 complete and 0 interrupted iterations +stress [ 24% ] 48/50 VUs 0m58.0s/4m00.0s + +running (0m59.0s), 49/50 VUs, 37 complete and 0 interrupted iterations +stress [ 25% ] 49/50 VUs 0m59.0s/4m00.0s + +running (1m00.0s), 49/50 VUs, 37 complete and 0 interrupted iterations +stress [ 25% ] 49/50 VUs 1m00.0s/4m00.0s + +running (1m01.0s), 50/50 VUs, 37 complete and 0 interrupted iterations +stress [ 25% ] 50/50 VUs 1m01.0s/4m00.0s + +running (1m02.0s), 50/50 VUs, 38 complete and 0 interrupted iterations +stress [ 26% ] 50/50 VUs 1m02.0s/4m00.0s + +running (1m03.0s), 50/50 VUs, 39 complete and 0 interrupted iterations +stress [ 26% ] 50/50 VUs 1m03.0s/4m00.0s + +running (1m04.0s), 50/50 VUs, 39 complete and 0 interrupted iterations +stress [ 27% ] 50/50 VUs 1m04.0s/4m00.0s + +running (1m05.0s), 50/50 VUs, 42 complete and 0 interrupted iterations +stress [ 27% ] 50/50 VUs 1m05.0s/4m00.0s + +running (1m06.0s), 50/50 VUs, 42 complete and 0 interrupted iterations +stress [ 27% ] 50/50 VUs 1m06.0s/4m00.0s + +running (1m07.0s), 50/50 VUs, 44 complete and 0 interrupted iterations +stress [ 28% ] 50/50 VUs 1m07.0s/4m00.0s + +running (1m08.0s), 50/50 VUs, 44 complete and 0 interrupted iterations +stress [ 28% ] 50/50 VUs 1m08.0s/4m00.0s + +running (1m09.0s), 50/50 VUs, 46 complete and 0 interrupted iterations +stress [ 29% ] 50/50 VUs 1m09.0s/4m00.0s + +running (1m10.0s), 50/50 VUs, 47 complete and 0 interrupted iterations +stress [ 29% ] 50/50 VUs 1m10.0s/4m00.0s + +running (1m11.0s), 50/50 VUs, 48 complete and 0 interrupted iterations +stress [ 30% ] 50/50 VUs 1m11.0s/4m00.0s + +running (1m12.0s), 50/50 VUs, 50 complete and 0 interrupted iterations +stress [ 30% ] 50/50 VUs 1m12.0s/4m00.0s + +running (1m13.0s), 50/50 VUs, 51 complete and 0 interrupted iterations +stress [ 30% ] 50/50 VUs 1m13.0s/4m00.0s + +running (1m14.0s), 50/50 VUs, 51 complete and 0 interrupted iterations +stress [ 31% ] 50/50 VUs 1m14.0s/4m00.0s + +running (1m15.0s), 50/50 VUs, 52 complete and 0 interrupted iterations +stress [ 31% ] 50/50 VUs 1m15.0s/4m00.0s + +running (1m16.0s), 50/50 VUs, 53 complete and 0 interrupted iterations +stress [ 32% ] 50/50 VUs 1m16.0s/4m00.0s + +running (1m17.0s), 50/50 VUs, 54 complete and 0 interrupted iterations +stress [ 32% ] 50/50 VUs 1m17.0s/4m00.0s + +running (1m18.0s), 50/50 VUs, 54 complete and 0 interrupted iterations +stress [ 32% ] 50/50 VUs 1m18.0s/4m00.0s + +running (1m19.0s), 50/50 VUs, 56 complete and 0 interrupted iterations +stress [ 33% ] 50/50 VUs 1m19.0s/4m00.0s + +running (1m20.0s), 50/50 VUs, 57 complete and 0 interrupted iterations +stress [ 33% ] 50/50 VUs 1m20.0s/4m00.0s + +running (1m21.0s), 50/50 VUs, 58 complete and 0 interrupted iterations +stress [ 34% ] 50/50 VUs 1m21.0s/4m00.0s + +running (1m22.0s), 50/50 VUs, 58 complete and 0 interrupted iterations +stress [ 34% ] 50/50 VUs 1m22.0s/4m00.0s + +running (1m23.0s), 50/50 VUs, 59 complete and 0 interrupted iterations +stress [ 35% ] 50/50 VUs 1m23.0s/4m00.0s + +running (1m24.0s), 50/50 VUs, 62 complete and 0 interrupted iterations +stress [ 35% ] 50/50 VUs 1m24.0s/4m00.0s + +running (1m25.0s), 50/50 VUs, 63 complete and 0 interrupted iterations +stress [ 35% ] 50/50 VUs 1m25.0s/4m00.0s + +running (1m26.0s), 50/50 VUs, 63 complete and 0 interrupted iterations +stress [ 36% ] 50/50 VUs 1m26.0s/4m00.0s + +running (1m27.0s), 50/50 VUs, 66 complete and 0 interrupted iterations +stress [ 36% ] 50/50 VUs 1m27.0s/4m00.0s + +running (1m28.0s), 50/50 VUs, 66 complete and 0 interrupted iterations +stress [ 37% ] 50/50 VUs 1m28.0s/4m00.0s + +running (1m29.0s), 50/50 VUs, 66 complete and 0 interrupted iterations +stress [ 37% ] 50/50 VUs 1m29.0s/4m00.0s + +running (1m30.0s), 50/50 VUs, 68 complete and 0 interrupted iterations +stress [ 37% ] 50/50 VUs 1m30.0s/4m00.0s + +running (1m31.0s), 50/50 VUs, 70 complete and 0 interrupted iterations +stress [ 38% ] 50/50 VUs 1m31.0s/4m00.0s + +running (1m32.0s), 50/50 VUs, 71 complete and 0 interrupted iterations +stress [ 38% ] 50/50 VUs 1m32.0s/4m00.0s + +running (1m33.0s), 50/50 VUs, 77 complete and 0 interrupted iterations +stress [ 39% ] 50/50 VUs 1m33.0s/4m00.0s + +running (1m34.0s), 50/50 VUs, 79 complete and 0 interrupted iterations +stress [ 39% ] 50/50 VUs 1m34.0s/4m00.0s + +running (1m35.0s), 50/50 VUs, 80 complete and 0 interrupted iterations +stress [ 40% ] 50/50 VUs 1m35.0s/4m00.0s + +running (1m36.0s), 50/50 VUs, 81 complete and 0 interrupted iterations +stress [ 40% ] 50/50 VUs 1m36.0s/4m00.0s + +running (1m37.0s), 50/50 VUs, 82 complete and 0 interrupted iterations +stress [ 40% ] 50/50 VUs 1m37.0s/4m00.0s + +running (1m38.0s), 50/50 VUs, 83 complete and 0 interrupted iterations +stress [ 41% ] 50/50 VUs 1m38.0s/4m00.0s + +running (1m39.0s), 50/50 VUs, 83 complete and 0 interrupted iterations +stress [ 41% ] 50/50 VUs 1m39.0s/4m00.0s + +running (1m40.0s), 50/50 VUs, 84 complete and 0 interrupted iterations +stress [ 42% ] 50/50 VUs 1m40.0s/4m00.0s + +running (1m41.0s), 50/50 VUs, 85 complete and 0 interrupted iterations +stress [ 42% ] 50/50 VUs 1m41.0s/4m00.0s + +running (1m42.0s), 50/50 VUs, 88 complete and 0 interrupted iterations +stress [ 42% ] 50/50 VUs 1m42.0s/4m00.0s + +running (1m43.0s), 50/50 VUs, 92 complete and 0 interrupted iterations +stress [ 43% ] 50/50 VUs 1m43.0s/4m00.0s + +running (1m44.0s), 50/50 VUs, 92 complete and 0 interrupted iterations +stress [ 43% ] 50/50 VUs 1m44.0s/4m00.0s + +running (1m45.0s), 50/50 VUs, 92 complete and 0 interrupted iterations +stress [ 44% ] 50/50 VUs 1m45.0s/4m00.0s + +running (1m46.0s), 50/50 VUs, 93 complete and 0 interrupted iterations +stress [ 44% ] 50/50 VUs 1m46.0s/4m00.0s + +running (1m47.0s), 50/50 VUs, 93 complete and 0 interrupted iterations +stress [ 45% ] 50/50 VUs 1m47.0s/4m00.0s + +running (1m48.0s), 50/50 VUs, 96 complete and 0 interrupted iterations +stress [ 45% ] 50/50 VUs 1m48.0s/4m00.0s + +running (1m49.0s), 50/50 VUs, 97 complete and 0 interrupted iterations +stress [ 45% ] 50/50 VUs 1m49.0s/4m00.0s + +running (1m50.0s), 50/50 VUs, 99 complete and 0 interrupted iterations +stress [ 46% ] 50/50 VUs 1m50.0s/4m00.0s + +running (1m51.0s), 50/50 VUs, 100 complete and 0 interrupted iterations +stress [ 46% ] 50/50 VUs 1m51.0s/4m00.0s + +running (1m52.0s), 50/50 VUs, 102 complete and 0 interrupted iterations +stress [ 47% ] 50/50 VUs 1m52.0s/4m00.0s + +running (1m53.0s), 50/50 VUs, 102 complete and 0 interrupted iterations +stress [ 47% ] 50/50 VUs 1m53.0s/4m00.0s + +running (1m54.0s), 50/50 VUs, 102 complete and 0 interrupted iterations +stress [ 47% ] 50/50 VUs 1m54.0s/4m00.0s + +running (1m55.0s), 50/50 VUs, 104 complete and 0 interrupted iterations +stress [ 48% ] 50/50 VUs 1m55.0s/4m00.0s + +running (1m56.0s), 50/50 VUs, 105 complete and 0 interrupted iterations +stress [ 48% ] 50/50 VUs 1m56.0s/4m00.0s + +running (1m57.0s), 50/50 VUs, 106 complete and 0 interrupted iterations +stress [ 49% ] 50/50 VUs 1m57.0s/4m00.0s + +running (1m58.0s), 50/50 VUs, 107 complete and 0 interrupted iterations +stress [ 49% ] 50/50 VUs 1m58.0s/4m00.0s + +running (1m59.0s), 50/50 VUs, 107 complete and 0 interrupted iterations +stress [ 50% ] 50/50 VUs 1m59.0s/4m00.0s + +running (2m00.0s), 50/50 VUs, 107 complete and 0 interrupted iterations +stress [ 50% ] 50/50 VUs 2m00.0s/4m00.0s + +running (2m01.0s), 50/50 VUs, 108 complete and 0 interrupted iterations +stress [ 50% ] 50/50 VUs 2m01.0s/4m00.0s + +running (2m02.0s), 50/50 VUs, 108 complete and 0 interrupted iterations +stress [ 51% ] 50/50 VUs 2m02.0s/4m00.0s + +running (2m03.0s), 50/50 VUs, 109 complete and 0 interrupted iterations +stress [ 51% ] 50/50 VUs 2m03.0s/4m00.0s + +running (2m04.0s), 50/50 VUs, 111 complete and 0 interrupted iterations +stress [ 52% ] 50/50 VUs 2m04.0s/4m00.0s + +running (2m05.0s), 50/50 VUs, 112 complete and 0 interrupted iterations +stress [ 52% ] 50/50 VUs 2m05.0s/4m00.0s + +running (2m06.0s), 50/50 VUs, 115 complete and 0 interrupted iterations +stress [ 52% ] 50/50 VUs 2m06.0s/4m00.0s + +running (2m07.0s), 50/50 VUs, 117 complete and 0 interrupted iterations +stress [ 53% ] 50/50 VUs 2m07.0s/4m00.0s + +running (2m08.0s), 50/50 VUs, 118 complete and 0 interrupted iterations +stress [ 53% ] 50/50 VUs 2m08.0s/4m00.0s + +running (2m09.0s), 50/50 VUs, 119 complete and 0 interrupted iterations +stress [ 54% ] 50/50 VUs 2m09.0s/4m00.0s + +running (2m10.0s), 50/50 VUs, 119 complete and 0 interrupted iterations +stress [ 54% ] 50/50 VUs 2m10.0s/4m00.0s + +running (2m11.0s), 50/50 VUs, 119 complete and 0 interrupted iterations +stress [ 55% ] 50/50 VUs 2m11.0s/4m00.0s + +running (2m12.0s), 50/50 VUs, 119 complete and 0 interrupted iterations +stress [ 55% ] 50/50 VUs 2m12.0s/4m00.0s + +running (2m13.0s), 50/50 VUs, 122 complete and 0 interrupted iterations +stress [ 55% ] 50/50 VUs 2m13.0s/4m00.0s + +running (2m14.0s), 50/50 VUs, 127 complete and 0 interrupted iterations +stress [ 56% ] 50/50 VUs 2m14.0s/4m00.0s + +running (2m15.0s), 50/50 VUs, 129 complete and 0 interrupted iterations +stress [ 56% ] 50/50 VUs 2m15.0s/4m00.0s + +running (2m16.0s), 50/50 VUs, 133 complete and 0 interrupted iterations +stress [ 57% ] 50/50 VUs 2m16.0s/4m00.0s + +running (2m17.0s), 50/50 VUs, 134 complete and 0 interrupted iterations +stress [ 57% ] 50/50 VUs 2m17.0s/4m00.0s + +running (2m18.0s), 50/50 VUs, 134 complete and 0 interrupted iterations +stress [ 58% ] 50/50 VUs 2m18.0s/4m00.0s + +running (2m19.0s), 50/50 VUs, 136 complete and 0 interrupted iterations +stress [ 58% ] 50/50 VUs 2m19.0s/4m00.0s + +running (2m20.0s), 50/50 VUs, 138 complete and 0 interrupted iterations +stress [ 58% ] 50/50 VUs 2m20.0s/4m00.0s + +running (2m21.0s), 50/50 VUs, 139 complete and 0 interrupted iterations +stress [ 59% ] 50/50 VUs 2m21.0s/4m00.0s + +running (2m22.0s), 50/50 VUs, 140 complete and 0 interrupted iterations +stress [ 59% ] 50/50 VUs 2m22.0s/4m00.0s + +running (2m23.0s), 50/50 VUs, 141 complete and 0 interrupted iterations +stress [ 60% ] 50/50 VUs 2m23.0s/4m00.0s + +running (2m24.0s), 50/50 VUs, 143 complete and 0 interrupted iterations +stress [ 60% ] 50/50 VUs 2m24.0s/4m00.0s + +running (2m25.0s), 50/50 VUs, 146 complete and 0 interrupted iterations +stress [ 60% ] 50/50 VUs 2m25.0s/4m00.0s + +running (2m26.0s), 50/50 VUs, 148 complete and 0 interrupted iterations +stress [ 61% ] 50/50 VUs 2m26.0s/4m00.0s + +running (2m27.0s), 50/50 VUs, 149 complete and 0 interrupted iterations +stress [ 61% ] 50/50 VUs 2m27.0s/4m00.0s + +running (2m28.0s), 50/50 VUs, 150 complete and 0 interrupted iterations +stress [ 62% ] 50/50 VUs 2m28.0s/4m00.0s + +running (2m29.0s), 50/50 VUs, 152 complete and 0 interrupted iterations +stress [ 62% ] 50/50 VUs 2m29.0s/4m00.0s + +running (2m30.0s), 50/50 VUs, 152 complete and 0 interrupted iterations +stress [ 62% ] 50/50 VUs 2m30.0s/4m00.0s + +running (2m31.0s), 50/50 VUs, 154 complete and 0 interrupted iterations +stress [ 63% ] 50/50 VUs 2m31.0s/4m00.0s + +running (2m32.0s), 50/50 VUs, 156 complete and 0 interrupted iterations +stress [ 63% ] 50/50 VUs 2m32.0s/4m00.0s + +running (2m33.0s), 50/50 VUs, 156 complete and 0 interrupted iterations +stress [ 64% ] 50/50 VUs 2m33.0s/4m00.0s + +running (2m34.0s), 50/50 VUs, 156 complete and 0 interrupted iterations +stress [ 64% ] 50/50 VUs 2m34.0s/4m00.0s + +running (2m35.0s), 50/50 VUs, 156 complete and 0 interrupted iterations +stress [ 65% ] 50/50 VUs 2m35.0s/4m00.0s + +running (2m36.0s), 50/50 VUs, 157 complete and 0 interrupted iterations +stress [ 65% ] 50/50 VUs 2m36.0s/4m00.0s + +running (2m37.0s), 50/50 VUs, 159 complete and 0 interrupted iterations +stress [ 65% ] 50/50 VUs 2m37.0s/4m00.0s + +running (2m38.0s), 50/50 VUs, 159 complete and 0 interrupted iterations +stress [ 66% ] 50/50 VUs 2m38.0s/4m00.0s + +running (2m39.0s), 50/50 VUs, 160 complete and 0 interrupted iterations +stress [ 66% ] 50/50 VUs 2m39.0s/4m00.0s + +running (2m40.0s), 50/50 VUs, 160 complete and 0 interrupted iterations +stress [ 67% ] 50/50 VUs 2m40.0s/4m00.0s + +running (2m41.0s), 50/50 VUs, 160 complete and 0 interrupted iterations +stress [ 67% ] 50/50 VUs 2m41.0s/4m00.0s + +running (2m42.0s), 50/50 VUs, 163 complete and 0 interrupted iterations +stress [ 67% ] 50/50 VUs 2m42.0s/4m00.0s + +running (2m43.0s), 50/50 VUs, 164 complete and 0 interrupted iterations +stress [ 68% ] 50/50 VUs 2m43.0s/4m00.0s + +running (2m44.0s), 50/50 VUs, 165 complete and 0 interrupted iterations +stress [ 68% ] 50/50 VUs 2m44.0s/4m00.0s + +running (2m45.0s), 50/50 VUs, 167 complete and 0 interrupted iterations +stress [ 69% ] 50/50 VUs 2m45.0s/4m00.0s + +running (2m46.0s), 50/50 VUs, 169 complete and 0 interrupted iterations +stress [ 69% ] 50/50 VUs 2m46.0s/4m00.0s + +running (2m47.0s), 50/50 VUs, 169 complete and 0 interrupted iterations +stress [ 70% ] 50/50 VUs 2m47.0s/4m00.0s + +running (2m48.0s), 50/50 VUs, 169 complete and 0 interrupted iterations +stress [ 70% ] 50/50 VUs 2m48.0s/4m00.0s + +running (2m49.0s), 50/50 VUs, 171 complete and 0 interrupted iterations +stress [ 70% ] 50/50 VUs 2m49.0s/4m00.0s + +running (2m50.0s), 50/50 VUs, 173 complete and 0 interrupted iterations +stress [ 71% ] 50/50 VUs 2m50.0s/4m00.0s + +running (2m51.0s), 50/50 VUs, 176 complete and 0 interrupted iterations +stress [ 71% ] 50/50 VUs 2m51.0s/4m00.0s + +running (2m52.0s), 50/50 VUs, 177 complete and 0 interrupted iterations +stress [ 72% ] 50/50 VUs 2m52.0s/4m00.0s + +running (2m53.0s), 50/50 VUs, 179 complete and 0 interrupted iterations +stress [ 72% ] 50/50 VUs 2m53.0s/4m00.0s + +running (2m54.0s), 50/50 VUs, 184 complete and 0 interrupted iterations +stress [ 72% ] 50/50 VUs 2m54.0s/4m00.0s + +running (2m55.0s), 50/50 VUs, 188 complete and 0 interrupted iterations +stress [ 73% ] 50/50 VUs 2m55.0s/4m00.0s + +running (2m56.0s), 50/50 VUs, 189 complete and 0 interrupted iterations +stress [ 73% ] 50/50 VUs 2m56.0s/4m00.0s + +running (2m57.0s), 50/50 VUs, 189 complete and 0 interrupted iterations +stress [ 74% ] 50/50 VUs 2m57.0s/4m00.0s + +running (2m58.0s), 50/50 VUs, 191 complete and 0 interrupted iterations +stress [ 74% ] 50/50 VUs 2m58.0s/4m00.0s + +running (2m59.0s), 50/50 VUs, 191 complete and 0 interrupted iterations +stress [ 75% ] 50/50 VUs 2m59.0s/4m00.0s + +running (3m00.0s), 50/50 VUs, 194 complete and 0 interrupted iterations +stress [ 75% ] 50/50 VUs 3m00.0s/4m00.0s + +running (3m01.0s), 50/50 VUs, 194 complete and 0 interrupted iterations +stress [ 75% ] 50/50 VUs 3m01.0s/4m00.0s + +running (3m02.0s), 50/50 VUs, 195 complete and 0 interrupted iterations +stress [ 76% ] 50/50 VUs 3m02.0s/4m00.0s + +running (3m03.0s), 50/50 VUs, 198 complete and 0 interrupted iterations +stress [ 76% ] 50/50 VUs 3m03.0s/4m00.0s + +running (3m04.0s), 50/50 VUs, 198 complete and 0 interrupted iterations +stress [ 77% ] 50/50 VUs 3m04.0s/4m00.0s + +running (3m05.0s), 50/50 VUs, 199 complete and 0 interrupted iterations +stress [ 77% ] 50/50 VUs 3m05.0s/4m00.0s + +running (3m06.0s), 50/50 VUs, 199 complete and 0 interrupted iterations +stress [ 77% ] 50/50 VUs 3m06.0s/4m00.0s + +running (3m07.0s), 50/50 VUs, 200 complete and 0 interrupted iterations +stress [ 78% ] 50/50 VUs 3m07.0s/4m00.0s + +running (3m08.0s), 50/50 VUs, 201 complete and 0 interrupted iterations +stress [ 78% ] 50/50 VUs 3m08.0s/4m00.0s + +running (3m09.0s), 50/50 VUs, 202 complete and 0 interrupted iterations +stress [ 79% ] 50/50 VUs 3m09.0s/4m00.0s + +running (3m10.0s), 50/50 VUs, 204 complete and 0 interrupted iterations +stress [ 79% ] 50/50 VUs 3m10.0s/4m00.0s + +running (3m11.0s), 50/50 VUs, 205 complete and 0 interrupted iterations +stress [ 80% ] 50/50 VUs 3m11.0s/4m00.0s + +running (3m12.0s), 50/50 VUs, 206 complete and 0 interrupted iterations +stress [ 80% ] 50/50 VUs 3m12.0s/4m00.0s + +running (3m13.0s), 50/50 VUs, 206 complete and 0 interrupted iterations +stress [ 80% ] 50/50 VUs 3m13.0s/4m00.0s + +running (3m14.0s), 50/50 VUs, 208 complete and 0 interrupted iterations +stress [ 81% ] 50/50 VUs 3m14.0s/4m00.0s + +running (3m15.0s), 50/50 VUs, 208 complete and 0 interrupted iterations +stress [ 81% ] 50/50 VUs 3m15.0s/4m00.0s + +running (3m16.0s), 50/50 VUs, 210 complete and 0 interrupted iterations +stress [ 82% ] 50/50 VUs 3m16.0s/4m00.0s + +running (3m17.0s), 50/50 VUs, 210 complete and 0 interrupted iterations +stress [ 82% ] 50/50 VUs 3m17.0s/4m00.0s + +running (3m18.0s), 50/50 VUs, 210 complete and 0 interrupted iterations +stress [ 82% ] 50/50 VUs 3m18.0s/4m00.0s + +running (3m19.0s), 49/50 VUs, 211 complete and 0 interrupted iterations +stress [ 83% ] 49/50 VUs 3m19.0s/4m00.0s + +running (3m20.0s), 49/50 VUs, 211 complete and 0 interrupted iterations +stress [ 83% ] 49/50 VUs 3m20.0s/4m00.0s + +running (3m21.0s), 49/50 VUs, 211 complete and 0 interrupted iterations +stress [ 84% ] 49/50 VUs 3m21.0s/4m00.0s + +running (3m22.0s), 49/50 VUs, 212 complete and 0 interrupted iterations +stress [ 84% ] 49/50 VUs 3m22.0s/4m00.0s + +running (3m23.0s), 46/50 VUs, 215 complete and 0 interrupted iterations +stress [ 85% ] 46/50 VUs 3m23.0s/4m00.0s + +running (3m24.0s), 45/50 VUs, 219 complete and 0 interrupted iterations +stress [ 85% ] 45/50 VUs 3m24.0s/4m00.0s + +running (3m25.0s), 45/50 VUs, 220 complete and 0 interrupted iterations +stress [ 85% ] 45/50 VUs 3m25.0s/4m00.0s + +running (3m26.0s), 45/50 VUs, 221 complete and 0 interrupted iterations +stress [ 86% ] 45/50 VUs 3m26.0s/4m00.0s + +running (3m27.0s), 44/50 VUs, 223 complete and 0 interrupted iterations +stress [ 86% ] 44/50 VUs 3m27.0s/4m00.0s + +running (3m28.0s), 43/50 VUs, 225 complete and 0 interrupted iterations +stress [ 87% ] 43/50 VUs 3m28.0s/4m00.0s + +running (3m29.0s), 43/50 VUs, 226 complete and 0 interrupted iterations +stress [ 87% ] 43/50 VUs 3m29.0s/4m00.0s + +running (3m30.0s), 41/50 VUs, 229 complete and 0 interrupted iterations +stress [ 87% ] 41/50 VUs 3m30.0s/4m00.0s + +running (3m31.0s), 41/50 VUs, 229 complete and 0 interrupted iterations +stress [ 88% ] 41/50 VUs 3m31.0s/4m00.0s + +running (3m32.0s), 39/50 VUs, 233 complete and 0 interrupted iterations +stress [ 88% ] 39/50 VUs 3m32.0s/4m00.0s + +running (3m33.0s), 37/50 VUs, 235 complete and 1 interrupted iterations +stress [ 89% ] 37/50 VUs 3m33.0s/4m00.0s + +running (3m34.0s), 36/50 VUs, 236 complete and 1 interrupted iterations +stress [ 89% ] 36/50 VUs 3m34.0s/4m00.0s + +running (3m35.0s), 36/50 VUs, 236 complete and 1 interrupted iterations +stress [ 90% ] 36/50 VUs 3m35.0s/4m00.0s + +running (3m36.0s), 33/50 VUs, 240 complete and 1 interrupted iterations +stress [ 90% ] 33/50 VUs 3m36.0s/4m00.0s + +running (3m37.0s), 30/50 VUs, 246 complete and 1 interrupted iterations +stress [ 90% ] 30/50 VUs 3m37.0s/4m00.0s + +running (3m38.0s), 30/50 VUs, 247 complete and 1 interrupted iterations +stress [ 91% ] 30/50 VUs 3m38.0s/4m00.0s + +running (3m39.0s), 30/50 VUs, 248 complete and 1 interrupted iterations +stress [ 91% ] 30/50 VUs 3m39.0s/4m00.0s + +running (3m40.0s), 27/50 VUs, 251 complete and 1 interrupted iterations +stress [ 92% ] 27/50 VUs 3m40.0s/4m00.0s + +running (3m41.0s), 26/50 VUs, 255 complete and 1 interrupted iterations +stress [ 92% ] 26/50 VUs 3m41.0s/4m00.0s + +running (3m42.0s), 25/50 VUs, 256 complete and 1 interrupted iterations +stress [ 92% ] 25/50 VUs 3m42.0s/4m00.0s + +running (3m43.0s), 23/50 VUs, 259 complete and 1 interrupted iterations +stress [ 93% ] 23/50 VUs 3m43.0s/4m00.0s + +running (3m44.0s), 23/50 VUs, 259 complete and 1 interrupted iterations +stress [ 93% ] 23/50 VUs 3m44.0s/4m00.0s + +running (3m45.0s), 23/50 VUs, 260 complete and 1 interrupted iterations +stress [ 94% ] 23/50 VUs 3m45.0s/4m00.0s + +running (3m46.0s), 21/50 VUs, 263 complete and 1 interrupted iterations +stress [ 94% ] 21/50 VUs 3m46.0s/4m00.0s + +running (3m47.0s), 20/50 VUs, 264 complete and 1 interrupted iterations +stress [ 95% ] 20/50 VUs 3m47.0s/4m00.0s + +running (3m48.0s), 19/50 VUs, 267 complete and 1 interrupted iterations +stress [ 95% ] 19/50 VUs 3m48.0s/4m00.0s + +running (3m49.0s), 17/50 VUs, 269 complete and 1 interrupted iterations +stress [ 95% ] 17/50 VUs 3m49.0s/4m00.0s + +running (3m50.0s), 16/50 VUs, 271 complete and 1 interrupted iterations +stress [ 96% ] 16/50 VUs 3m50.0s/4m00.0s + +running (3m51.0s), 16/50 VUs, 273 complete and 1 interrupted iterations +stress [ 96% ] 16/50 VUs 3m51.0s/4m00.0s + +running (3m52.0s), 15/50 VUs, 275 complete and 1 interrupted iterations +stress [ 97% ] 15/50 VUs 3m52.0s/4m00.0s + +running (3m53.0s), 13/50 VUs, 277 complete and 1 interrupted iterations +stress [ 97% ] 13/50 VUs 3m53.0s/4m00.0s + +running (3m54.0s), 10/50 VUs, 281 complete and 1 interrupted iterations +stress [ 97% ] 10/50 VUs 3m54.0s/4m00.0s + +running (3m55.0s), 08/50 VUs, 283 complete and 1 interrupted iterations +stress [ 98% ] 08/50 VUs 3m55.0s/4m00.0s + +running (3m56.0s), 08/50 VUs, 283 complete and 1 interrupted iterations +stress [ 98% ] 08/50 VUs 3m56.0s/4m00.0s + +running (3m57.0s), 07/50 VUs, 285 complete and 1 interrupted iterations +stress [ 99% ] 07/50 VUs 3m57.0s/4m00.0s + +running (3m58.0s), 05/50 VUs, 287 complete and 1 interrupted iterations +stress [ 99% ] 05/50 VUs 3m58.0s/4m00.0s + +running (3m59.0s), 05/50 VUs, 287 complete and 1 interrupted iterations +stress [ 100% ] 05/50 VUs 3m59.0s/4m00.0s + +running (4m00.0s), 03/50 VUs, 289 complete and 1 interrupted iterations +stress [ 100% ] 03/50 VUs 4m00.0s/4m00.0s + +running (4m01.0s), 02/50 VUs, 290 complete and 1 interrupted iterations +stress ↓ [ 100% ] 03/50 VUs 4m0s + +running (4m02.0s), 02/50 VUs, 290 complete and 1 interrupted iterations +stress ↓ [ 100% ] 03/50 VUs 4m0s + +running (4m03.0s), 01/50 VUs, 291 complete and 1 interrupted iterations +stress ↓ [ 100% ] 03/50 VUs 4m0s + +running (4m04.0s), 01/50 VUs, 291 complete and 1 interrupted iterations +stress ↓ [ 100% ] 03/50 VUs 4m0s + + + â–ˆ THRESHOLDS + + http_req_failed + ✓ 'rate<0.01' rate=0.10% + + + â–ˆ TOTAL RESULTS + + checks_total.......: 879 3.593992/s + checks_succeeded...: 99.88% 878 out of 879 + checks_failed......: 0.11% 1 out of 879 + + ✓ identity profile is 200 + ✓ identity request deletion is 200 + ✗ identity restore is 200 + ↳ 99% — ✓ 292 / ✗ 1 + + HTTP + http_req_duration..............: avg=9.04s min=375.21ms med=8.85s max=21.19s p(90)=15.25s p(95)=16.47s + { expected_response:true }...: avg=9.05s min=480.5ms med=8.87s max=21.19s p(90)=15.25s p(95)=16.47s + http_req_failed................: 0.10% 1 out of 929 + http_reqs......................: 929 3.798428/s + + EXECUTION + iteration_duration.............: avg=32.72s min=7.99s med=36.5s max=45.66s p(90)=41.03s p(95)=42.08s + iterations.....................: 292 1.193909/s + vus............................: 1 min=0 max=50 + vus_max........................: 50 min=50 max=50 + + NETWORK + data_received..................: 910 kB 3.7 kB/s + data_sent......................: 203 kB 830 B/s + + + + +running (4m04.6s), 00/50 VUs, 292 complete and 1 interrupted iterations +stress ✓ [ 100% ] 00/50 VUs 4m0s + diff --git a/spt/spt_results_summary.json b/spt/spt_results_summary.json new file mode 100644 index 0000000..928bca4 --- /dev/null +++ b/spt/spt_results_summary.json @@ -0,0 +1,62 @@ +[ + { + "service": "Identity", + "scenario": "baseline", + "checks_pct": "100.00%", + "req_failed_pct": "0.00%", + "avg_lat": "1.12s", + "p95_lat": "1.93s", + "reqs_sec": "0.412823/s", + "status": "Passed" + }, + { + "service": "Identity", + "scenario": "load", + "checks_pct": "100.00%", + "req_failed_pct": "0.00%", + "avg_lat": "1.79s", + "p95_lat": "3.58s", + "reqs_sec": "2.566254/s", + "status": "Passed" + }, + { + "service": "Identity", + "scenario": "stress", + "checks_pct": "99.88%", + "req_failed_pct": "0.10%", + "avg_lat": "9.04s", + "p95_lat": "16.47s", + "reqs_sec": "3.798428/s", + "status": "Passed" + }, + { + "service": "Subscription", + "scenario": "baseline", + "checks_pct": "100.00%", + "req_failed_pct": "0.00%", + "avg_lat": "396.12ms", + "p95_lat": "543.77ms", + "reqs_sec": "0.586969/s", + "status": "Passed" + }, + { + "service": "Subscription", + "scenario": "load", + "checks_pct": "100.00%", + "req_failed_pct": "0.00%", + "avg_lat": "371.33ms", + "p95_lat": "609.31ms", + "reqs_sec": "4.719487/s", + "status": "Passed" + }, + { + "service": "Subscription", + "scenario": "stress", + "checks_pct": "100.00%", + "req_failed_pct": "0.00%", + "avg_lat": "1.16s", + "p95_lat": "2.99s", + "reqs_sec": "15.24661/s", + "status": "Passed" + } +] \ No newline at end of file diff --git a/spt/spt_subscription_baseline.log b/spt/spt_subscription_baseline.log new file mode 100644 index 0000000..b903568 --- /dev/null +++ b/spt/spt_subscription_baseline.log @@ -0,0 +1,244 @@ + + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + + execution: local + script: spt/subscription-spt.js + output: - + + scenarios: (100.00%) 1 scenario, 1 max VUs, 1m30s max duration (incl. graceful stop): + * baseline: 1 looping VUs for 1m0s (gracefulStop: 30s) + + +running (0m01.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 2% ] 1 VUs 0m01.0s/1m0s + +running (0m02.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 3% ] 1 VUs 0m02.0s/1m0s + +running (0m03.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 5% ] 1 VUs 0m03.0s/1m0s + +running (0m04.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 7% ] 1 VUs 0m04.0s/1m0s + +running (0m05.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 8% ] 1 VUs 0m05.0s/1m0s + +running (0m06.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 10% ] 1 VUs 0m06.0s/1m0s + +running (0m07.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 12% ] 1 VUs 0m07.0s/1m0s + +running (0m08.0s), 1/1 VUs, 0 complete and 0 interrupted iterations +baseline [ 13% ] 1 VUs 0m08.0s/1m0s + +running (0m09.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 15% ] 1 VUs 0m09.0s/1m0s + +running (0m10.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 17% ] 1 VUs 0m10.0s/1m0s + +running (0m11.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 18% ] 1 VUs 0m11.0s/1m0s + +running (0m12.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 20% ] 1 VUs 0m12.0s/1m0s + +running (0m13.0s), 1/1 VUs, 1 complete and 0 interrupted iterations +baseline [ 22% ] 1 VUs 0m13.0s/1m0s + +running (0m14.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 23% ] 1 VUs 0m14.0s/1m0s + +running (0m15.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 25% ] 1 VUs 0m15.0s/1m0s + +running (0m16.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 27% ] 1 VUs 0m16.0s/1m0s + +running (0m17.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 28% ] 1 VUs 0m17.0s/1m0s + +running (0m18.0s), 1/1 VUs, 2 complete and 0 interrupted iterations +baseline [ 30% ] 1 VUs 0m18.0s/1m0s + +running (0m19.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 32% ] 1 VUs 0m19.0s/1m0s + +running (0m20.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 33% ] 1 VUs 0m20.0s/1m0s + +running (0m21.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 35% ] 1 VUs 0m21.0s/1m0s + +running (0m22.0s), 1/1 VUs, 3 complete and 0 interrupted iterations +baseline [ 37% ] 1 VUs 0m22.0s/1m0s + +running (0m23.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 38% ] 1 VUs 0m23.0s/1m0s + +running (0m24.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 40% ] 1 VUs 0m24.0s/1m0s + +running (0m25.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 42% ] 1 VUs 0m25.0s/1m0s + +running (0m26.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 43% ] 1 VUs 0m26.0s/1m0s + +running (0m27.0s), 1/1 VUs, 4 complete and 0 interrupted iterations +baseline [ 45% ] 1 VUs 0m27.0s/1m0s + +running (0m28.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 47% ] 1 VUs 0m28.0s/1m0s + +running (0m29.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 48% ] 1 VUs 0m29.0s/1m0s + +running (0m30.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 50% ] 1 VUs 0m30.0s/1m0s + +running (0m31.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 52% ] 1 VUs 0m31.0s/1m0s + +running (0m32.0s), 1/1 VUs, 5 complete and 0 interrupted iterations +baseline [ 53% ] 1 VUs 0m32.0s/1m0s + +running (0m33.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 55% ] 1 VUs 0m33.0s/1m0s + +running (0m34.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 57% ] 1 VUs 0m34.0s/1m0s + +running (0m35.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 58% ] 1 VUs 0m35.0s/1m0s + +running (0m36.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 60% ] 1 VUs 0m36.0s/1m0s + +running (0m37.0s), 1/1 VUs, 6 complete and 0 interrupted iterations +baseline [ 62% ] 1 VUs 0m37.0s/1m0s + +running (0m38.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 63% ] 1 VUs 0m38.0s/1m0s + +running (0m39.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 65% ] 1 VUs 0m39.0s/1m0s + +running (0m40.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 67% ] 1 VUs 0m40.0s/1m0s + +running (0m41.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 68% ] 1 VUs 0m41.0s/1m0s + +running (0m42.0s), 1/1 VUs, 7 complete and 0 interrupted iterations +baseline [ 70% ] 1 VUs 0m42.0s/1m0s + +running (0m43.0s), 1/1 VUs, 8 complete and 0 interrupted iterations +baseline [ 72% ] 1 VUs 0m43.0s/1m0s + +running (0m44.0s), 1/1 VUs, 8 complete and 0 interrupted iterations +baseline [ 73% ] 1 VUs 0m44.0s/1m0s + +running (0m45.0s), 1/1 VUs, 8 complete and 0 interrupted iterations +baseline [ 75% ] 1 VUs 0m45.0s/1m0s + +running (0m46.0s), 1/1 VUs, 8 complete and 0 interrupted iterations +baseline [ 77% ] 1 VUs 0m46.0s/1m0s + +running (0m47.0s), 1/1 VUs, 8 complete and 0 interrupted iterations +baseline [ 78% ] 1 VUs 0m47.0s/1m0s + +running (0m48.0s), 1/1 VUs, 9 complete and 0 interrupted iterations +baseline [ 80% ] 1 VUs 0m48.0s/1m0s + +running (0m49.0s), 1/1 VUs, 9 complete and 0 interrupted iterations +baseline [ 82% ] 1 VUs 0m49.0s/1m0s + +running (0m50.0s), 1/1 VUs, 9 complete and 0 interrupted iterations +baseline [ 83% ] 1 VUs 0m50.0s/1m0s + +running (0m51.0s), 1/1 VUs, 9 complete and 0 interrupted iterations +baseline [ 85% ] 1 VUs 0m51.0s/1m0s + +running (0m52.0s), 1/1 VUs, 9 complete and 0 interrupted iterations +baseline [ 87% ] 1 VUs 0m52.0s/1m0s + +running (0m53.0s), 1/1 VUs, 10 complete and 0 interrupted iterations +baseline [ 88% ] 1 VUs 0m53.0s/1m0s + +running (0m54.0s), 1/1 VUs, 10 complete and 0 interrupted iterations +baseline [ 90% ] 1 VUs 0m54.0s/1m0s + +running (0m55.0s), 1/1 VUs, 10 complete and 0 interrupted iterations +baseline [ 92% ] 1 VUs 0m55.0s/1m0s + +running (0m56.0s), 1/1 VUs, 10 complete and 0 interrupted iterations +baseline [ 93% ] 1 VUs 0m56.0s/1m0s + +running (0m57.0s), 1/1 VUs, 10 complete and 0 interrupted iterations +baseline [ 95% ] 1 VUs 0m57.0s/1m0s + +running (0m58.0s), 1/1 VUs, 10 complete and 0 interrupted iterations +baseline [ 97% ] 1 VUs 0m58.0s/1m0s + +running (0m59.0s), 1/1 VUs, 11 complete and 0 interrupted iterations +baseline [ 98% ] 1 VUs 0m59.0s/1m0s + +running (1m00.0s), 1/1 VUs, 11 complete and 0 interrupted iterations +baseline [ 100% ] 1 VUs 1m0s + +running (1m01.0s), 1/1 VUs, 11 complete and 0 interrupted iterations +baseline ↓ [ 100% ] 1 VUs 1m0s + +running (1m02.0s), 1/1 VUs, 11 complete and 0 interrupted iterations +baseline ↓ [ 100% ] 1 VUs 1m0s + +running (1m03.0s), 1/1 VUs, 11 complete and 0 interrupted iterations +baseline ↓ [ 100% ] 1 VUs 1m0s + + + â–ˆ THRESHOLDS + + http_req_failed + ✓ 'rate<0.01' rate=0.00% + + + â–ˆ TOTAL RESULTS + + checks_total.......: 36 0.571105/s + checks_succeeded...: 100.00% 36 out of 36 + checks_failed......: 0.00% 0 out of 36 + + ✓ subscription plans list is 200 + ✓ my subscription details is 200 + ✓ usage history is 200 + + HTTP + http_req_duration..............: avg=396.12ms min=277.6ms med=301.06ms max=2.63s p(90)=490.92ms p(95)=543.77ms + { expected_response:true }...: avg=396.12ms min=277.6ms med=301.06ms max=2.63s p(90)=490.92ms p(95)=543.77ms + http_req_failed................: 0.00% 0 out of 37 + http_reqs......................: 37 0.586969/s + + EXECUTION + iteration_duration.............: avg=5.25s min=4.85s med=4.93s max=8.24s p(90)=5.26s p(95)=6.61s + iterations.....................: 12 0.190368/s + vus............................: 1 min=1 max=1 + vus_max........................: 1 min=1 max=1 + + NETWORK + data_received..................: 74 kB 1.2 kB/s + data_sent......................: 4.7 kB 75 B/s + + + + +running (1m03.0s), 0/1 VUs, 12 complete and 0 interrupted iterations +baseline ✓ [ 100% ] 1 VUs 1m0s + diff --git a/spt/spt_subscription_load.log b/spt/spt_subscription_load.log new file mode 100644 index 0000000..1c8cbf5 --- /dev/null +++ b/spt/spt_subscription_load.log @@ -0,0 +1,961 @@ + + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + + execution: local + script: spt/subscription-spt.js + output: - + + scenarios: (100.00%) 1 scenario, 10 max VUs, 5m30s max duration (incl. graceful stop): + * load: Up to 10 looping VUs for 5m0s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s) + + +running (0m01.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 0% ] 00/10 VUs 0m01.0s/5m00.0s + +running (0m02.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 1% ] 00/10 VUs 0m02.0s/5m00.0s + +running (0m03.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 1% ] 00/10 VUs 0m03.0s/5m00.0s + +running (0m04.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 1% ] 00/10 VUs 0m04.0s/5m00.0s + +running (0m05.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 2% ] 00/10 VUs 0m05.0s/5m00.0s + +running (0m06.0s), 00/10 VUs, 0 complete and 0 interrupted iterations +load [ 2% ] 00/10 VUs 0m06.0s/5m00.0s + +running (0m07.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 2% ] 01/10 VUs 0m07.0s/5m00.0s + +running (0m08.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 3% ] 01/10 VUs 0m08.0s/5m00.0s + +running (0m09.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 3% ] 01/10 VUs 0m09.0s/5m00.0s + +running (0m10.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 3% ] 01/10 VUs 0m10.0s/5m00.0s + +running (0m11.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 4% ] 01/10 VUs 0m11.0s/5m00.0s + +running (0m12.0s), 01/10 VUs, 0 complete and 0 interrupted iterations +load [ 4% ] 01/10 VUs 0m12.0s/5m00.0s + +running (0m13.0s), 02/10 VUs, 0 complete and 0 interrupted iterations +load [ 4% ] 02/10 VUs 0m13.0s/5m00.0s + +running (0m14.0s), 02/10 VUs, 0 complete and 0 interrupted iterations +load [ 5% ] 02/10 VUs 0m14.0s/5m00.0s + +running (0m15.0s), 02/10 VUs, 1 complete and 0 interrupted iterations +load [ 5% ] 02/10 VUs 0m15.0s/5m00.0s + +running (0m16.0s), 02/10 VUs, 1 complete and 0 interrupted iterations +load [ 5% ] 02/10 VUs 0m16.0s/5m00.0s + +running (0m17.0s), 02/10 VUs, 1 complete and 0 interrupted iterations +load [ 6% ] 02/10 VUs 0m17.0s/5m00.0s + +running (0m18.0s), 02/10 VUs, 1 complete and 0 interrupted iterations +load [ 6% ] 02/10 VUs 0m18.0s/5m00.0s + +running (0m19.0s), 03/10 VUs, 1 complete and 0 interrupted iterations +load [ 6% ] 03/10 VUs 0m19.0s/5m00.0s + +running (0m20.0s), 03/10 VUs, 3 complete and 0 interrupted iterations +load [ 7% ] 03/10 VUs 0m20.0s/5m00.0s + +running (0m21.0s), 03/10 VUs, 3 complete and 0 interrupted iterations +load [ 7% ] 03/10 VUs 0m21.0s/5m00.0s + +running (0m22.0s), 03/10 VUs, 3 complete and 0 interrupted iterations +load [ 7% ] 03/10 VUs 0m22.0s/5m00.0s + +running (0m23.0s), 03/10 VUs, 3 complete and 0 interrupted iterations +load [ 8% ] 03/10 VUs 0m23.0s/5m00.0s + +running (0m24.0s), 03/10 VUs, 3 complete and 0 interrupted iterations +load [ 8% ] 03/10 VUs 0m24.0s/5m00.0s + +running (0m25.0s), 04/10 VUs, 5 complete and 0 interrupted iterations +load [ 8% ] 04/10 VUs 0m25.0s/5m00.0s + +running (0m26.0s), 04/10 VUs, 5 complete and 0 interrupted iterations +load [ 9% ] 04/10 VUs 0m26.0s/5m00.0s + +running (0m27.0s), 04/10 VUs, 6 complete and 0 interrupted iterations +load [ 9% ] 04/10 VUs 0m27.0s/5m00.0s + +running (0m28.0s), 04/10 VUs, 6 complete and 0 interrupted iterations +load [ 9% ] 04/10 VUs 0m28.0s/5m00.0s + +running (0m29.0s), 04/10 VUs, 6 complete and 0 interrupted iterations +load [ 10% ] 04/10 VUs 0m29.0s/5m00.0s + +running (0m30.0s), 04/10 VUs, 8 complete and 0 interrupted iterations +load [ 10% ] 04/10 VUs 0m30.0s/5m00.0s + +running (0m31.0s), 05/10 VUs, 8 complete and 0 interrupted iterations +load [ 10% ] 05/10 VUs 0m31.0s/5m00.0s + +running (0m32.0s), 05/10 VUs, 10 complete and 0 interrupted iterations +load [ 11% ] 05/10 VUs 0m32.0s/5m00.0s + +running (0m33.0s), 05/10 VUs, 10 complete and 0 interrupted iterations +load [ 11% ] 05/10 VUs 0m33.0s/5m00.0s + +running (0m34.0s), 05/10 VUs, 10 complete and 0 interrupted iterations +load [ 11% ] 05/10 VUs 0m34.0s/5m00.0s + +running (0m35.0s), 05/10 VUs, 12 complete and 0 interrupted iterations +load [ 12% ] 05/10 VUs 0m35.0s/5m00.0s + +running (0m36.0s), 05/10 VUs, 12 complete and 0 interrupted iterations +load [ 12% ] 05/10 VUs 0m36.0s/5m00.0s + +running (0m37.0s), 06/10 VUs, 14 complete and 0 interrupted iterations +load [ 12% ] 06/10 VUs 0m37.0s/5m00.0s + +running (0m38.0s), 06/10 VUs, 15 complete and 0 interrupted iterations +load [ 13% ] 06/10 VUs 0m38.0s/5m00.0s + +running (0m39.0s), 06/10 VUs, 15 complete and 0 interrupted iterations +load [ 13% ] 06/10 VUs 0m39.0s/5m00.0s + +running (0m40.0s), 06/10 VUs, 17 complete and 0 interrupted iterations +load [ 13% ] 06/10 VUs 0m40.0s/5m00.0s + +running (0m41.0s), 06/10 VUs, 17 complete and 0 interrupted iterations +load [ 14% ] 06/10 VUs 0m41.0s/5m00.0s + +running (0m42.0s), 06/10 VUs, 19 complete and 0 interrupted iterations +load [ 14% ] 06/10 VUs 0m42.0s/5m00.0s + +running (0m43.0s), 07/10 VUs, 20 complete and 0 interrupted iterations +load [ 14% ] 07/10 VUs 0m43.0s/5m00.0s + +running (0m44.0s), 07/10 VUs, 20 complete and 0 interrupted iterations +load [ 15% ] 07/10 VUs 0m44.0s/5m00.0s + +running (0m45.0s), 07/10 VUs, 23 complete and 0 interrupted iterations +load [ 15% ] 07/10 VUs 0m45.0s/5m00.0s + +running (0m46.0s), 07/10 VUs, 23 complete and 0 interrupted iterations +load [ 15% ] 07/10 VUs 0m46.0s/5m00.0s + +running (0m47.0s), 07/10 VUs, 25 complete and 0 interrupted iterations +load [ 16% ] 07/10 VUs 0m47.0s/5m00.0s + +running (0m48.0s), 07/10 VUs, 27 complete and 0 interrupted iterations +load [ 16% ] 07/10 VUs 0m48.0s/5m00.0s + +running (0m49.0s), 08/10 VUs, 27 complete and 0 interrupted iterations +load [ 16% ] 08/10 VUs 0m49.0s/5m00.0s + +running (0m50.0s), 08/10 VUs, 30 complete and 0 interrupted iterations +load [ 17% ] 08/10 VUs 0m50.0s/5m00.0s + +running (0m51.0s), 08/10 VUs, 30 complete and 0 interrupted iterations +load [ 17% ] 08/10 VUs 0m51.0s/5m00.0s + +running (0m52.0s), 08/10 VUs, 31 complete and 0 interrupted iterations +load [ 17% ] 08/10 VUs 0m52.0s/5m00.0s + +running (0m53.0s), 08/10 VUs, 34 complete and 0 interrupted iterations +load [ 18% ] 08/10 VUs 0m53.0s/5m00.0s + +running (0m54.0s), 08/10 VUs, 34 complete and 0 interrupted iterations +load [ 18% ] 08/10 VUs 0m54.0s/5m00.0s + +running (0m55.0s), 09/10 VUs, 37 complete and 0 interrupted iterations +load [ 18% ] 09/10 VUs 0m55.0s/5m00.0s + +running (0m56.0s), 09/10 VUs, 38 complete and 0 interrupted iterations +load [ 19% ] 09/10 VUs 0m56.0s/5m00.0s + +running (0m57.0s), 09/10 VUs, 39 complete and 0 interrupted iterations +load [ 19% ] 09/10 VUs 0m57.0s/5m00.0s + +running (0m58.0s), 09/10 VUs, 42 complete and 0 interrupted iterations +load [ 19% ] 09/10 VUs 0m58.0s/5m00.0s + +running (0m59.0s), 09/10 VUs, 42 complete and 0 interrupted iterations +load [ 20% ] 09/10 VUs 0m59.0s/5m00.0s + +running (1m00.0s), 09/10 VUs, 45 complete and 0 interrupted iterations +load [ 20% ] 09/10 VUs 1m00.0s/5m00.0s + +running (1m01.0s), 10/10 VUs, 45 complete and 0 interrupted iterations +load [ 20% ] 10/10 VUs 1m01.0s/5m00.0s + +running (1m02.0s), 10/10 VUs, 46 complete and 0 interrupted iterations +load [ 21% ] 10/10 VUs 1m02.0s/5m00.0s + +running (1m03.0s), 10/10 VUs, 51 complete and 0 interrupted iterations +load [ 21% ] 10/10 VUs 1m03.0s/5m00.0s + +running (1m04.0s), 10/10 VUs, 51 complete and 0 interrupted iterations +load [ 21% ] 10/10 VUs 1m04.0s/5m00.0s + +running (1m05.0s), 10/10 VUs, 54 complete and 0 interrupted iterations +load [ 22% ] 10/10 VUs 1m05.0s/5m00.0s + +running (1m06.0s), 10/10 VUs, 54 complete and 0 interrupted iterations +load [ 22% ] 10/10 VUs 1m06.0s/5m00.0s + +running (1m07.0s), 10/10 VUs, 55 complete and 0 interrupted iterations +load [ 22% ] 10/10 VUs 1m07.0s/5m00.0s + +running (1m08.0s), 10/10 VUs, 61 complete and 0 interrupted iterations +load [ 23% ] 10/10 VUs 1m08.0s/5m00.0s + +running (1m09.0s), 10/10 VUs, 61 complete and 0 interrupted iterations +load [ 23% ] 10/10 VUs 1m09.0s/5m00.0s + +running (1m10.0s), 10/10 VUs, 64 complete and 0 interrupted iterations +load [ 23% ] 10/10 VUs 1m10.0s/5m00.0s + +running (1m11.0s), 10/10 VUs, 64 complete and 0 interrupted iterations +load [ 24% ] 10/10 VUs 1m11.0s/5m00.0s + +running (1m12.0s), 10/10 VUs, 65 complete and 0 interrupted iterations +load [ 24% ] 10/10 VUs 1m12.0s/5m00.0s + +running (1m13.0s), 10/10 VUs, 71 complete and 0 interrupted iterations +load [ 24% ] 10/10 VUs 1m13.0s/5m00.0s + +running (1m14.0s), 10/10 VUs, 71 complete and 0 interrupted iterations +load [ 25% ] 10/10 VUs 1m14.0s/5m00.0s + +running (1m15.0s), 10/10 VUs, 74 complete and 0 interrupted iterations +load [ 25% ] 10/10 VUs 1m15.0s/5m00.0s + +running (1m16.0s), 10/10 VUs, 74 complete and 0 interrupted iterations +load [ 25% ] 10/10 VUs 1m16.0s/5m00.0s + +running (1m17.0s), 10/10 VUs, 75 complete and 0 interrupted iterations +load [ 26% ] 10/10 VUs 1m17.0s/5m00.0s + +running (1m18.0s), 10/10 VUs, 80 complete and 0 interrupted iterations +load [ 26% ] 10/10 VUs 1m18.0s/5m00.0s + +running (1m19.0s), 10/10 VUs, 81 complete and 0 interrupted iterations +load [ 26% ] 10/10 VUs 1m19.0s/5m00.0s + +running (1m20.0s), 10/10 VUs, 84 complete and 0 interrupted iterations +load [ 27% ] 10/10 VUs 1m20.0s/5m00.0s + +running (1m21.0s), 10/10 VUs, 84 complete and 0 interrupted iterations +load [ 27% ] 10/10 VUs 1m21.0s/5m00.0s + +running (1m22.0s), 10/10 VUs, 85 complete and 0 interrupted iterations +load [ 27% ] 10/10 VUs 1m22.0s/5m00.0s + +running (1m23.0s), 10/10 VUs, 89 complete and 0 interrupted iterations +load [ 28% ] 10/10 VUs 1m23.0s/5m00.0s + +running (1m24.0s), 10/10 VUs, 91 complete and 0 interrupted iterations +load [ 28% ] 10/10 VUs 1m24.0s/5m00.0s + +running (1m25.0s), 10/10 VUs, 94 complete and 0 interrupted iterations +load [ 28% ] 10/10 VUs 1m25.0s/5m00.0s + +running (1m26.0s), 10/10 VUs, 94 complete and 0 interrupted iterations +load [ 29% ] 10/10 VUs 1m26.0s/5m00.0s + +running (1m27.0s), 10/10 VUs, 95 complete and 0 interrupted iterations +load [ 29% ] 10/10 VUs 1m27.0s/5m00.0s + +running (1m28.0s), 10/10 VUs, 98 complete and 0 interrupted iterations +load [ 29% ] 10/10 VUs 1m28.0s/5m00.0s + +running (1m29.0s), 10/10 VUs, 98 complete and 0 interrupted iterations +load [ 30% ] 10/10 VUs 1m29.0s/5m00.0s + +running (1m30.0s), 10/10 VUs, 102 complete and 0 interrupted iterations +load [ 30% ] 10/10 VUs 1m30.0s/5m00.0s + +running (1m31.0s), 10/10 VUs, 102 complete and 0 interrupted iterations +load [ 30% ] 10/10 VUs 1m31.0s/5m00.0s + +running (1m32.0s), 10/10 VUs, 104 complete and 0 interrupted iterations +load [ 31% ] 10/10 VUs 1m32.0s/5m00.0s + +running (1m33.0s), 10/10 VUs, 108 complete and 0 interrupted iterations +load [ 31% ] 10/10 VUs 1m33.0s/5m00.0s + +running (1m34.0s), 10/10 VUs, 108 complete and 0 interrupted iterations +load [ 31% ] 10/10 VUs 1m34.0s/5m00.0s + +running (1m35.0s), 10/10 VUs, 112 complete and 0 interrupted iterations +load [ 32% ] 10/10 VUs 1m35.0s/5m00.0s + +running (1m36.0s), 10/10 VUs, 112 complete and 0 interrupted iterations +load [ 32% ] 10/10 VUs 1m36.0s/5m00.0s + +running (1m37.0s), 10/10 VUs, 114 complete and 0 interrupted iterations +load [ 32% ] 10/10 VUs 1m37.0s/5m00.0s + +running (1m38.0s), 10/10 VUs, 116 complete and 0 interrupted iterations +load [ 33% ] 10/10 VUs 1m38.0s/5m00.0s + +running (1m39.0s), 10/10 VUs, 118 complete and 0 interrupted iterations +load [ 33% ] 10/10 VUs 1m39.0s/5m00.0s + +running (1m40.0s), 10/10 VUs, 119 complete and 0 interrupted iterations +load [ 33% ] 10/10 VUs 1m40.0s/5m00.0s + +running (1m41.0s), 10/10 VUs, 122 complete and 0 interrupted iterations +load [ 34% ] 10/10 VUs 1m41.0s/5m00.0s + +running (1m42.0s), 10/10 VUs, 124 complete and 0 interrupted iterations +load [ 34% ] 10/10 VUs 1m42.0s/5m00.0s + +running (1m43.0s), 10/10 VUs, 125 complete and 0 interrupted iterations +load [ 34% ] 10/10 VUs 1m43.0s/5m00.0s + +running (1m44.0s), 10/10 VUs, 128 complete and 0 interrupted iterations +load [ 35% ] 10/10 VUs 1m44.0s/5m00.0s + +running (1m45.0s), 10/10 VUs, 129 complete and 0 interrupted iterations +load [ 35% ] 10/10 VUs 1m45.0s/5m00.0s + +running (1m46.0s), 10/10 VUs, 132 complete and 0 interrupted iterations +load [ 35% ] 10/10 VUs 1m46.0s/5m00.0s + +running (1m47.0s), 10/10 VUs, 134 complete and 0 interrupted iterations +load [ 36% ] 10/10 VUs 1m47.0s/5m00.0s + +running (1m48.0s), 10/10 VUs, 135 complete and 0 interrupted iterations +load [ 36% ] 10/10 VUs 1m48.0s/5m00.0s + +running (1m49.0s), 10/10 VUs, 138 complete and 0 interrupted iterations +load [ 36% ] 10/10 VUs 1m49.0s/5m00.0s + +running (1m50.0s), 10/10 VUs, 139 complete and 0 interrupted iterations +load [ 37% ] 10/10 VUs 1m50.0s/5m00.0s + +running (1m51.0s), 10/10 VUs, 142 complete and 0 interrupted iterations +load [ 37% ] 10/10 VUs 1m51.0s/5m00.0s + +running (1m52.0s), 10/10 VUs, 143 complete and 0 interrupted iterations +load [ 37% ] 10/10 VUs 1m52.0s/5m00.0s + +running (1m53.0s), 10/10 VUs, 145 complete and 0 interrupted iterations +load [ 38% ] 10/10 VUs 1m53.0s/5m00.0s + +running (1m54.0s), 10/10 VUs, 148 complete and 0 interrupted iterations +load [ 38% ] 10/10 VUs 1m54.0s/5m00.0s + +running (1m55.0s), 10/10 VUs, 150 complete and 0 interrupted iterations +load [ 38% ] 10/10 VUs 1m55.0s/5m00.0s + +running (1m56.0s), 10/10 VUs, 152 complete and 0 interrupted iterations +load [ 39% ] 10/10 VUs 1m56.0s/5m00.0s + +running (1m57.0s), 10/10 VUs, 154 complete and 0 interrupted iterations +load [ 39% ] 10/10 VUs 1m57.0s/5m00.0s + +running (1m58.0s), 10/10 VUs, 155 complete and 0 interrupted iterations +load [ 39% ] 10/10 VUs 1m58.0s/5m00.0s + +running (1m59.0s), 10/10 VUs, 158 complete and 0 interrupted iterations +load [ 40% ] 10/10 VUs 1m59.0s/5m00.0s + +running (2m00.0s), 10/10 VUs, 162 complete and 0 interrupted iterations +load [ 40% ] 10/10 VUs 2m00.0s/5m00.0s + +running (2m01.0s), 10/10 VUs, 162 complete and 0 interrupted iterations +load [ 40% ] 10/10 VUs 2m01.0s/5m00.0s + +running (2m02.0s), 10/10 VUs, 164 complete and 0 interrupted iterations +load [ 41% ] 10/10 VUs 2m02.0s/5m00.0s + +running (2m03.0s), 10/10 VUs, 165 complete and 0 interrupted iterations +load [ 41% ] 10/10 VUs 2m03.0s/5m00.0s + +running (2m04.0s), 10/10 VUs, 168 complete and 0 interrupted iterations +load [ 41% ] 10/10 VUs 2m04.0s/5m00.0s + +running (2m05.0s), 10/10 VUs, 172 complete and 0 interrupted iterations +load [ 42% ] 10/10 VUs 2m05.0s/5m00.0s + +running (2m06.0s), 10/10 VUs, 172 complete and 0 interrupted iterations +load [ 42% ] 10/10 VUs 2m06.0s/5m00.0s + +running (2m07.0s), 10/10 VUs, 174 complete and 0 interrupted iterations +load [ 42% ] 10/10 VUs 2m07.0s/5m00.0s + +running (2m08.0s), 10/10 VUs, 174 complete and 0 interrupted iterations +load [ 43% ] 10/10 VUs 2m08.0s/5m00.0s + +running (2m09.0s), 10/10 VUs, 177 complete and 0 interrupted iterations +load [ 43% ] 10/10 VUs 2m09.0s/5m00.0s + +running (2m10.0s), 10/10 VUs, 178 complete and 0 interrupted iterations +load [ 43% ] 10/10 VUs 2m10.0s/5m00.0s + +running (2m11.0s), 10/10 VUs, 181 complete and 0 interrupted iterations +load [ 44% ] 10/10 VUs 2m11.0s/5m00.0s + +running (2m12.0s), 10/10 VUs, 184 complete and 0 interrupted iterations +load [ 44% ] 10/10 VUs 2m12.0s/5m00.0s + +running (2m13.0s), 10/10 VUs, 184 complete and 0 interrupted iterations +load [ 44% ] 10/10 VUs 2m13.0s/5m00.0s + +running (2m14.0s), 10/10 VUs, 187 complete and 0 interrupted iterations +load [ 45% ] 10/10 VUs 2m14.0s/5m00.0s + +running (2m15.0s), 10/10 VUs, 188 complete and 0 interrupted iterations +load [ 45% ] 10/10 VUs 2m15.0s/5m00.0s + +running (2m16.0s), 10/10 VUs, 192 complete and 0 interrupted iterations +load [ 45% ] 10/10 VUs 2m16.0s/5m00.0s + +running (2m17.0s), 10/10 VUs, 192 complete and 0 interrupted iterations +load [ 46% ] 10/10 VUs 2m17.0s/5m00.0s + +running (2m18.0s), 10/10 VUs, 194 complete and 0 interrupted iterations +load [ 46% ] 10/10 VUs 2m18.0s/5m00.0s + +running (2m19.0s), 10/10 VUs, 197 complete and 0 interrupted iterations +load [ 46% ] 10/10 VUs 2m19.0s/5m00.0s + +running (2m20.0s), 10/10 VUs, 198 complete and 0 interrupted iterations +load [ 47% ] 10/10 VUs 2m20.0s/5m00.0s + +running (2m21.0s), 10/10 VUs, 202 complete and 0 interrupted iterations +load [ 47% ] 10/10 VUs 2m21.0s/5m00.0s + +running (2m22.0s), 10/10 VUs, 202 complete and 0 interrupted iterations +load [ 47% ] 10/10 VUs 2m22.0s/5m00.0s + +running (2m23.0s), 10/10 VUs, 204 complete and 0 interrupted iterations +load [ 48% ] 10/10 VUs 2m23.0s/5m00.0s + +running (2m24.0s), 10/10 VUs, 207 complete and 0 interrupted iterations +load [ 48% ] 10/10 VUs 2m24.0s/5m00.0s + +running (2m25.0s), 10/10 VUs, 208 complete and 0 interrupted iterations +load [ 48% ] 10/10 VUs 2m25.0s/5m00.0s + +running (2m26.0s), 10/10 VUs, 211 complete and 0 interrupted iterations +load [ 49% ] 10/10 VUs 2m26.0s/5m00.0s + +running (2m27.0s), 10/10 VUs, 212 complete and 0 interrupted iterations +load [ 49% ] 10/10 VUs 2m27.0s/5m00.0s + +running (2m28.0s), 10/10 VUs, 214 complete and 0 interrupted iterations +load [ 49% ] 10/10 VUs 2m28.0s/5m00.0s + +running (2m29.0s), 10/10 VUs, 217 complete and 0 interrupted iterations +load [ 50% ] 10/10 VUs 2m29.0s/5m00.0s + +running (2m30.0s), 10/10 VUs, 218 complete and 0 interrupted iterations +load [ 50% ] 10/10 VUs 2m30.0s/5m00.0s + +running (2m31.0s), 10/10 VUs, 221 complete and 0 interrupted iterations +load [ 50% ] 10/10 VUs 2m31.0s/5m00.0s + +running (2m32.0s), 10/10 VUs, 222 complete and 0 interrupted iterations +load [ 51% ] 10/10 VUs 2m32.0s/5m00.0s + +running (2m33.0s), 10/10 VUs, 224 complete and 0 interrupted iterations +load [ 51% ] 10/10 VUs 2m33.0s/5m00.0s + +running (2m34.0s), 10/10 VUs, 227 complete and 0 interrupted iterations +load [ 51% ] 10/10 VUs 2m34.0s/5m00.0s + +running (2m35.0s), 10/10 VUs, 228 complete and 0 interrupted iterations +load [ 52% ] 10/10 VUs 2m35.0s/5m00.0s + +running (2m36.0s), 10/10 VUs, 231 complete and 0 interrupted iterations +load [ 52% ] 10/10 VUs 2m36.0s/5m00.0s + +running (2m37.0s), 10/10 VUs, 232 complete and 0 interrupted iterations +load [ 52% ] 10/10 VUs 2m37.0s/5m00.0s + +running (2m38.0s), 10/10 VUs, 234 complete and 0 interrupted iterations +load [ 53% ] 10/10 VUs 2m38.0s/5m00.0s + +running (2m39.0s), 10/10 VUs, 237 complete and 0 interrupted iterations +load [ 53% ] 10/10 VUs 2m39.0s/5m00.0s + +running (2m40.0s), 10/10 VUs, 238 complete and 0 interrupted iterations +load [ 53% ] 10/10 VUs 2m40.0s/5m00.0s + +running (2m41.0s), 10/10 VUs, 240 complete and 0 interrupted iterations +load [ 54% ] 10/10 VUs 2m41.0s/5m00.0s + +running (2m42.0s), 10/10 VUs, 242 complete and 0 interrupted iterations +load [ 54% ] 10/10 VUs 2m42.0s/5m00.0s + +running (2m43.0s), 10/10 VUs, 244 complete and 0 interrupted iterations +load [ 54% ] 10/10 VUs 2m43.0s/5m00.0s + +running (2m44.0s), 10/10 VUs, 248 complete and 0 interrupted iterations +load [ 55% ] 10/10 VUs 2m44.0s/5m00.0s + +running (2m45.0s), 10/10 VUs, 248 complete and 0 interrupted iterations +load [ 55% ] 10/10 VUs 2m45.0s/5m00.0s + +running (2m46.0s), 10/10 VUs, 252 complete and 0 interrupted iterations +load [ 55% ] 10/10 VUs 2m46.0s/5m00.0s + +running (2m47.0s), 10/10 VUs, 252 complete and 0 interrupted iterations +load [ 56% ] 10/10 VUs 2m47.0s/5m00.0s + +running (2m48.0s), 10/10 VUs, 254 complete and 0 interrupted iterations +load [ 56% ] 10/10 VUs 2m48.0s/5m00.0s + +running (2m49.0s), 10/10 VUs, 258 complete and 0 interrupted iterations +load [ 56% ] 10/10 VUs 2m49.0s/5m00.0s + +running (2m50.0s), 10/10 VUs, 258 complete and 0 interrupted iterations +load [ 57% ] 10/10 VUs 2m50.0s/5m00.0s + +running (2m51.0s), 10/10 VUs, 262 complete and 0 interrupted iterations +load [ 57% ] 10/10 VUs 2m51.0s/5m00.0s + +running (2m52.0s), 10/10 VUs, 262 complete and 0 interrupted iterations +load [ 57% ] 10/10 VUs 2m52.0s/5m00.0s + +running (2m53.0s), 10/10 VUs, 264 complete and 0 interrupted iterations +load [ 58% ] 10/10 VUs 2m53.0s/5m00.0s + +running (2m54.0s), 10/10 VUs, 267 complete and 0 interrupted iterations +load [ 58% ] 10/10 VUs 2m54.0s/5m00.0s + +running (2m55.0s), 10/10 VUs, 268 complete and 0 interrupted iterations +load [ 58% ] 10/10 VUs 2m55.0s/5m00.0s + +running (2m56.0s), 10/10 VUs, 272 complete and 0 interrupted iterations +load [ 59% ] 10/10 VUs 2m56.0s/5m00.0s + +running (2m57.0s), 10/10 VUs, 274 complete and 0 interrupted iterations +load [ 59% ] 10/10 VUs 2m57.0s/5m00.0s + +running (2m58.0s), 10/10 VUs, 274 complete and 0 interrupted iterations +load [ 59% ] 10/10 VUs 2m58.0s/5m00.0s + +running (2m59.0s), 10/10 VUs, 278 complete and 0 interrupted iterations +load [ 60% ] 10/10 VUs 2m59.0s/5m00.0s + +running (3m00.0s), 10/10 VUs, 278 complete and 0 interrupted iterations +load [ 60% ] 10/10 VUs 3m00.0s/5m00.0s + +running (3m01.0s), 10/10 VUs, 282 complete and 0 interrupted iterations +load [ 60% ] 10/10 VUs 3m01.0s/5m00.0s + +running (3m02.0s), 10/10 VUs, 283 complete and 0 interrupted iterations +load [ 61% ] 10/10 VUs 3m02.0s/5m00.0s + +running (3m03.0s), 10/10 VUs, 284 complete and 0 interrupted iterations +load [ 61% ] 10/10 VUs 3m03.0s/5m00.0s + +running (3m04.0s), 10/10 VUs, 288 complete and 0 interrupted iterations +load [ 61% ] 10/10 VUs 3m04.0s/5m00.0s + +running (3m05.0s), 10/10 VUs, 288 complete and 0 interrupted iterations +load [ 62% ] 10/10 VUs 3m05.0s/5m00.0s + +running (3m06.0s), 10/10 VUs, 292 complete and 0 interrupted iterations +load [ 62% ] 10/10 VUs 3m06.0s/5m00.0s + +running (3m07.0s), 10/10 VUs, 293 complete and 0 interrupted iterations +load [ 62% ] 10/10 VUs 3m07.0s/5m00.0s + +running (3m08.0s), 10/10 VUs, 294 complete and 0 interrupted iterations +load [ 63% ] 10/10 VUs 3m08.0s/5m00.0s + +running (3m09.0s), 10/10 VUs, 298 complete and 0 interrupted iterations +load [ 63% ] 10/10 VUs 3m09.0s/5m00.0s + +running (3m10.0s), 10/10 VUs, 299 complete and 0 interrupted iterations +load [ 63% ] 10/10 VUs 3m10.0s/5m00.0s + +running (3m11.0s), 10/10 VUs, 302 complete and 0 interrupted iterations +load [ 64% ] 10/10 VUs 3m11.0s/5m00.0s + +running (3m12.0s), 10/10 VUs, 302 complete and 0 interrupted iterations +load [ 64% ] 10/10 VUs 3m12.0s/5m00.0s + +running (3m13.0s), 10/10 VUs, 304 complete and 0 interrupted iterations +load [ 64% ] 10/10 VUs 3m13.0s/5m00.0s + +running (3m14.0s), 10/10 VUs, 306 complete and 0 interrupted iterations +load [ 65% ] 10/10 VUs 3m14.0s/5m00.0s + +running (3m15.0s), 10/10 VUs, 309 complete and 0 interrupted iterations +load [ 65% ] 10/10 VUs 3m15.0s/5m00.0s + +running (3m16.0s), 10/10 VUs, 312 complete and 0 interrupted iterations +load [ 65% ] 10/10 VUs 3m16.0s/5m00.0s + +running (3m17.0s), 10/10 VUs, 314 complete and 0 interrupted iterations +load [ 66% ] 10/10 VUs 3m17.0s/5m00.0s + +running (3m18.0s), 10/10 VUs, 314 complete and 0 interrupted iterations +load [ 66% ] 10/10 VUs 3m18.0s/5m00.0s + +running (3m19.0s), 10/10 VUs, 317 complete and 0 interrupted iterations +load [ 66% ] 10/10 VUs 3m19.0s/5m00.0s + +running (3m20.0s), 10/10 VUs, 319 complete and 0 interrupted iterations +load [ 67% ] 10/10 VUs 3m20.0s/5m00.0s + +running (3m21.0s), 10/10 VUs, 322 complete and 0 interrupted iterations +load [ 67% ] 10/10 VUs 3m21.0s/5m00.0s + +running (3m22.0s), 10/10 VUs, 322 complete and 0 interrupted iterations +load [ 67% ] 10/10 VUs 3m22.0s/5m00.0s + +running (3m23.0s), 10/10 VUs, 324 complete and 0 interrupted iterations +load [ 68% ] 10/10 VUs 3m23.0s/5m00.0s + +running (3m24.0s), 10/10 VUs, 324 complete and 0 interrupted iterations +load [ 68% ] 10/10 VUs 3m24.0s/5m00.0s + +running (3m25.0s), 10/10 VUs, 329 complete and 0 interrupted iterations +load [ 68% ] 10/10 VUs 3m25.0s/5m00.0s + +running (3m26.0s), 10/10 VUs, 332 complete and 0 interrupted iterations +load [ 69% ] 10/10 VUs 3m26.0s/5m00.0s + +running (3m27.0s), 10/10 VUs, 332 complete and 0 interrupted iterations +load [ 69% ] 10/10 VUs 3m27.0s/5m00.0s + +running (3m28.0s), 10/10 VUs, 334 complete and 0 interrupted iterations +load [ 69% ] 10/10 VUs 3m28.0s/5m00.0s + +running (3m29.0s), 10/10 VUs, 336 complete and 0 interrupted iterations +load [ 70% ] 10/10 VUs 3m29.0s/5m00.0s + +running (3m30.0s), 10/10 VUs, 339 complete and 0 interrupted iterations +load [ 70% ] 10/10 VUs 3m30.0s/5m00.0s + +running (3m31.0s), 10/10 VUs, 342 complete and 0 interrupted iterations +load [ 70% ] 10/10 VUs 3m31.0s/5m00.0s + +running (3m32.0s), 10/10 VUs, 342 complete and 0 interrupted iterations +load [ 71% ] 10/10 VUs 3m32.0s/5m00.0s + +running (3m33.0s), 10/10 VUs, 344 complete and 0 interrupted iterations +load [ 71% ] 10/10 VUs 3m33.0s/5m00.0s + +running (3m34.0s), 10/10 VUs, 347 complete and 0 interrupted iterations +load [ 71% ] 10/10 VUs 3m34.0s/5m00.0s + +running (3m35.0s), 10/10 VUs, 349 complete and 0 interrupted iterations +load [ 72% ] 10/10 VUs 3m35.0s/5m00.0s + +running (3m36.0s), 10/10 VUs, 352 complete and 0 interrupted iterations +load [ 72% ] 10/10 VUs 3m36.0s/5m00.0s + +running (3m37.0s), 10/10 VUs, 352 complete and 0 interrupted iterations +load [ 72% ] 10/10 VUs 3m37.0s/5m00.0s + +running (3m38.0s), 10/10 VUs, 354 complete and 0 interrupted iterations +load [ 73% ] 10/10 VUs 3m38.0s/5m00.0s + +running (3m39.0s), 10/10 VUs, 354 complete and 0 interrupted iterations +load [ 73% ] 10/10 VUs 3m39.0s/5m00.0s + +running (3m40.0s), 10/10 VUs, 359 complete and 0 interrupted iterations +load [ 73% ] 10/10 VUs 3m40.0s/5m00.0s + +running (3m41.0s), 10/10 VUs, 362 complete and 0 interrupted iterations +load [ 74% ] 10/10 VUs 3m41.0s/5m00.0s + +running (3m42.0s), 10/10 VUs, 362 complete and 0 interrupted iterations +load [ 74% ] 10/10 VUs 3m42.0s/5m00.0s + +running (3m43.0s), 10/10 VUs, 364 complete and 0 interrupted iterations +load [ 74% ] 10/10 VUs 3m43.0s/5m00.0s + +running (3m44.0s), 10/10 VUs, 364 complete and 0 interrupted iterations +load [ 75% ] 10/10 VUs 3m44.0s/5m00.0s + +running (3m45.0s), 10/10 VUs, 369 complete and 0 interrupted iterations +load [ 75% ] 10/10 VUs 3m45.0s/5m00.0s + +running (3m46.0s), 10/10 VUs, 372 complete and 0 interrupted iterations +load [ 75% ] 10/10 VUs 3m46.0s/5m00.0s + +running (3m47.0s), 10/10 VUs, 373 complete and 0 interrupted iterations +load [ 76% ] 10/10 VUs 3m47.0s/5m00.0s + +running (3m48.0s), 10/10 VUs, 374 complete and 0 interrupted iterations +load [ 76% ] 10/10 VUs 3m48.0s/5m00.0s + +running (3m49.0s), 10/10 VUs, 374 complete and 0 interrupted iterations +load [ 76% ] 10/10 VUs 3m49.0s/5m00.0s + +running (3m50.0s), 10/10 VUs, 379 complete and 0 interrupted iterations +load [ 77% ] 10/10 VUs 3m50.0s/5m00.0s + +running (3m51.0s), 10/10 VUs, 381 complete and 0 interrupted iterations +load [ 77% ] 10/10 VUs 3m51.0s/5m00.0s + +running (3m52.0s), 10/10 VUs, 382 complete and 0 interrupted iterations +load [ 77% ] 10/10 VUs 3m52.0s/5m00.0s + +running (3m53.0s), 10/10 VUs, 384 complete and 0 interrupted iterations +load [ 78% ] 10/10 VUs 3m53.0s/5m00.0s + +running (3m54.0s), 10/10 VUs, 384 complete and 0 interrupted iterations +load [ 78% ] 10/10 VUs 3m54.0s/5m00.0s + +running (3m55.0s), 10/10 VUs, 389 complete and 0 interrupted iterations +load [ 78% ] 10/10 VUs 3m55.0s/5m00.0s + +running (3m56.0s), 10/10 VUs, 392 complete and 0 interrupted iterations +load [ 79% ] 10/10 VUs 3m56.0s/5m00.0s + +running (3m57.0s), 10/10 VUs, 392 complete and 0 interrupted iterations +load [ 79% ] 10/10 VUs 3m57.0s/5m00.0s + +running (3m58.0s), 10/10 VUs, 394 complete and 0 interrupted iterations +load [ 79% ] 10/10 VUs 3m58.0s/5m00.0s + +running (3m59.0s), 10/10 VUs, 395 complete and 0 interrupted iterations +load [ 80% ] 10/10 VUs 3m59.0s/5m00.0s + +running (4m00.0s), 10/10 VUs, 398 complete and 0 interrupted iterations +load [ 80% ] 10/10 VUs 4m00.0s/5m00.0s + +running (4m01.0s), 10/10 VUs, 401 complete and 0 interrupted iterations +load [ 80% ] 10/10 VUs 4m01.0s/5m00.0s + +running (4m02.0s), 10/10 VUs, 402 complete and 0 interrupted iterations +load [ 81% ] 10/10 VUs 4m02.0s/5m00.0s + +running (4m03.0s), 10/10 VUs, 404 complete and 0 interrupted iterations +load [ 81% ] 10/10 VUs 4m03.0s/5m00.0s + +running (4m04.0s), 10/10 VUs, 406 complete and 0 interrupted iterations +load [ 81% ] 10/10 VUs 4m04.0s/5m00.0s + +running (4m05.0s), 10/10 VUs, 408 complete and 0 interrupted iterations +load [ 82% ] 10/10 VUs 4m05.0s/5m00.0s + +running (4m06.0s), 10/10 VUs, 411 complete and 0 interrupted iterations +load [ 82% ] 10/10 VUs 4m06.0s/5m00.0s + +running (4m07.0s), 10/10 VUs, 412 complete and 0 interrupted iterations +load [ 82% ] 10/10 VUs 4m07.0s/5m00.0s + +running (4m08.0s), 10/10 VUs, 414 complete and 0 interrupted iterations +load [ 83% ] 10/10 VUs 4m08.0s/5m00.0s + +running (4m09.0s), 10/10 VUs, 416 complete and 0 interrupted iterations +load [ 83% ] 10/10 VUs 4m09.0s/5m00.0s + +running (4m10.0s), 10/10 VUs, 419 complete and 0 interrupted iterations +load [ 83% ] 10/10 VUs 4m10.0s/5m00.0s + +running (4m11.0s), 09/10 VUs, 421 complete and 0 interrupted iterations +load [ 84% ] 09/10 VUs 4m11.0s/5m00.0s + +running (4m12.0s), 09/10 VUs, 423 complete and 0 interrupted iterations +load [ 84% ] 09/10 VUs 4m12.0s/5m00.0s + +running (4m13.0s), 09/10 VUs, 424 complete and 0 interrupted iterations +load [ 84% ] 09/10 VUs 4m13.0s/5m00.0s + +running (4m14.0s), 09/10 VUs, 426 complete and 0 interrupted iterations +load [ 85% ] 09/10 VUs 4m14.0s/5m00.0s + +running (4m15.0s), 08/10 VUs, 429 complete and 0 interrupted iterations +load [ 85% ] 08/10 VUs 4m15.0s/5m00.0s + +running (4m16.0s), 08/10 VUs, 430 complete and 0 interrupted iterations +load [ 85% ] 08/10 VUs 4m16.0s/5m00.0s + +running (4m17.0s), 08/10 VUs, 432 complete and 0 interrupted iterations +load [ 86% ] 08/10 VUs 4m17.0s/5m00.0s + +running (4m18.0s), 08/10 VUs, 433 complete and 0 interrupted iterations +load [ 86% ] 08/10 VUs 4m18.0s/5m00.0s + +running (4m19.0s), 08/10 VUs, 433 complete and 0 interrupted iterations +load [ 86% ] 08/10 VUs 4m19.0s/5m00.0s + +running (4m20.0s), 08/10 VUs, 433 complete and 0 interrupted iterations +load [ 87% ] 08/10 VUs 4m20.0s/5m00.0s + +running (4m21.0s), 07/10 VUs, 436 complete and 0 interrupted iterations +load [ 87% ] 07/10 VUs 4m21.0s/5m00.0s + +running (4m22.0s), 07/10 VUs, 437 complete and 0 interrupted iterations +load [ 87% ] 07/10 VUs 4m22.0s/5m00.0s + +running (4m23.0s), 07/10 VUs, 441 complete and 0 interrupted iterations +load [ 88% ] 07/10 VUs 4m23.0s/5m00.0s + +running (4m24.0s), 07/10 VUs, 441 complete and 0 interrupted iterations +load [ 88% ] 07/10 VUs 4m24.0s/5m00.0s + +running (4m25.0s), 07/10 VUs, 441 complete and 0 interrupted iterations +load [ 88% ] 07/10 VUs 4m25.0s/5m00.0s + +running (4m26.0s), 07/10 VUs, 444 complete and 0 interrupted iterations +load [ 89% ] 07/10 VUs 4m26.0s/5m00.0s + +running (4m27.0s), 07/10 VUs, 444 complete and 0 interrupted iterations +load [ 89% ] 07/10 VUs 4m27.0s/5m00.0s + +running (4m28.0s), 06/10 VUs, 448 complete and 0 interrupted iterations +load [ 89% ] 06/10 VUs 4m28.0s/5m00.0s + +running (4m29.0s), 06/10 VUs, 448 complete and 0 interrupted iterations +load [ 90% ] 06/10 VUs 4m29.0s/5m00.0s + +running (4m30.0s), 06/10 VUs, 448 complete and 0 interrupted iterations +load [ 90% ] 06/10 VUs 4m30.0s/5m00.0s + +running (4m31.0s), 05/10 VUs, 451 complete and 0 interrupted iterations +load [ 90% ] 05/10 VUs 4m31.0s/5m00.0s + +running (4m32.0s), 05/10 VUs, 451 complete and 0 interrupted iterations +load [ 91% ] 05/10 VUs 4m32.0s/5m00.0s + +running (4m33.0s), 05/10 VUs, 454 complete and 0 interrupted iterations +load [ 91% ] 05/10 VUs 4m33.0s/5m00.0s + +running (4m34.0s), 05/10 VUs, 454 complete and 0 interrupted iterations +load [ 91% ] 05/10 VUs 4m34.0s/5m00.0s + +running (4m35.0s), 05/10 VUs, 454 complete and 0 interrupted iterations +load [ 92% ] 05/10 VUs 4m35.0s/5m00.0s + +running (4m36.0s), 05/10 VUs, 456 complete and 0 interrupted iterations +load [ 92% ] 05/10 VUs 4m36.0s/5m00.0s + +running (4m37.0s), 05/10 VUs, 456 complete and 0 interrupted iterations +load [ 92% ] 05/10 VUs 4m37.0s/5m00.0s + +running (4m38.0s), 05/10 VUs, 459 complete and 0 interrupted iterations +load [ 93% ] 05/10 VUs 4m38.0s/5m00.0s + +running (4m39.0s), 05/10 VUs, 459 complete and 0 interrupted iterations +load [ 93% ] 05/10 VUs 4m39.0s/5m00.0s + +running (4m40.0s), 05/10 VUs, 459 complete and 0 interrupted iterations +load [ 93% ] 05/10 VUs 4m40.0s/5m00.0s + +running (4m41.0s), 04/10 VUs, 461 complete and 0 interrupted iterations +load [ 94% ] 04/10 VUs 4m41.0s/5m00.0s + +running (4m42.0s), 04/10 VUs, 461 complete and 0 interrupted iterations +load [ 94% ] 04/10 VUs 4m42.0s/5m00.0s + +running (4m43.0s), 03/10 VUs, 464 complete and 0 interrupted iterations +load [ 94% ] 03/10 VUs 4m43.0s/5m00.0s + +running (4m44.0s), 03/10 VUs, 464 complete and 0 interrupted iterations +load [ 95% ] 03/10 VUs 4m44.0s/5m00.0s + +running (4m45.0s), 03/10 VUs, 464 complete and 0 interrupted iterations +load [ 95% ] 03/10 VUs 4m45.0s/5m00.0s + +running (4m46.0s), 03/10 VUs, 465 complete and 0 interrupted iterations +load [ 95% ] 03/10 VUs 4m46.0s/5m00.0s + +running (4m47.0s), 03/10 VUs, 465 complete and 0 interrupted iterations +load [ 96% ] 03/10 VUs 4m47.0s/5m00.0s + +running (4m48.0s), 03/10 VUs, 467 complete and 0 interrupted iterations +load [ 96% ] 03/10 VUs 4m48.0s/5m00.0s + +running (4m49.0s), 03/10 VUs, 467 complete and 0 interrupted iterations +load [ 96% ] 03/10 VUs 4m49.0s/5m00.0s + +running (4m50.0s), 03/10 VUs, 467 complete and 0 interrupted iterations +load [ 97% ] 03/10 VUs 4m50.0s/5m00.0s + +running (4m51.0s), 02/10 VUs, 468 complete and 0 interrupted iterations +load [ 97% ] 02/10 VUs 4m51.0s/5m00.0s + +running (4m52.0s), 02/10 VUs, 468 complete and 0 interrupted iterations +load [ 97% ] 02/10 VUs 4m52.0s/5m00.0s + +running (4m53.0s), 02/10 VUs, 469 complete and 0 interrupted iterations +load [ 98% ] 02/10 VUs 4m53.0s/5m00.0s + +running (4m54.0s), 02/10 VUs, 470 complete and 0 interrupted iterations +load [ 98% ] 02/10 VUs 4m54.0s/5m00.0s + +running (4m55.0s), 02/10 VUs, 470 complete and 0 interrupted iterations +load [ 98% ] 02/10 VUs 4m55.0s/5m00.0s + +running (4m56.0s), 02/10 VUs, 470 complete and 0 interrupted iterations +load [ 99% ] 02/10 VUs 4m56.0s/5m00.0s + +running (4m57.0s), 02/10 VUs, 470 complete and 0 interrupted iterations +load [ 99% ] 02/10 VUs 4m57.0s/5m00.0s + +running (4m58.0s), 01/10 VUs, 472 complete and 0 interrupted iterations +load [ 99% ] 01/10 VUs 4m58.0s/5m00.0s + +running (4m59.0s), 01/10 VUs, 472 complete and 0 interrupted iterations +load [ 100% ] 01/10 VUs 4m59.0s/5m00.0s + +running (5m00.0s), 01/10 VUs, 472 complete and 0 interrupted iterations +load [ 100% ] 01/10 VUs 5m00.0s/5m00.0s + +running (5m01.0s), 01/10 VUs, 472 complete and 0 interrupted iterations +load ↓ [ 100% ] 01/10 VUs 5m0s + +running (5m02.0s), 01/10 VUs, 472 complete and 0 interrupted iterations +load ↓ [ 100% ] 01/10 VUs 5m0s + + + â–ˆ THRESHOLDS + + http_req_failed + ✓ 'rate<0.01' rate=0.00% + + + â–ˆ TOTAL RESULTS + + checks_total.......: 1419 4.686461/s + checks_succeeded...: 100.00% 1419 out of 1419 + checks_failed......: 0.00% 0 out of 1419 + + ✓ subscription plans list is 200 + ✓ my subscription details is 200 + ✓ usage history is 200 + + HTTP + http_req_duration..............: avg=371.33ms min=270.44ms med=311.47ms max=2.87s p(90)=455.35ms p(95)=609.31ms + { expected_response:true }...: avg=371.33ms min=270.44ms med=311.47ms max=2.87s p(90)=455.35ms p(95)=609.31ms + http_req_failed................: 0.00% 0 out of 1429 + http_reqs......................: 1429 4.719487/s + + EXECUTION + iteration_duration.............: avg=5.13s min=4.83s med=4.99s max=8.66s p(90)=5.36s p(95)=5.94s + iterations.....................: 473 1.562154/s + vus............................: 1 min=0 max=10 + vus_max........................: 10 min=10 max=10 + + NETWORK + data_received..................: 2.7 MB 8.9 kB/s + data_sent......................: 100 kB 330 B/s + + + + +running (5m02.8s), 00/10 VUs, 473 complete and 0 interrupted iterations +load ✓ [ 100% ] 00/10 VUs 5m0s + diff --git a/spt/spt_subscription_stress.log b/spt/spt_subscription_stress.log new file mode 100644 index 0000000..86e85e8 --- /dev/null +++ b/spt/spt_subscription_stress.log @@ -0,0 +1,778 @@ + + /\ Grafana /‾‾/ + /\ / \ |\ __ / / + / \/ \ | |/ / / ‾‾\ + / \ | ( | (‾) | + / __________ \ |_|\_\ \_____/ + + + execution: local + script: spt/subscription-spt.js + output: - + + scenarios: (100.00%) 1 scenario, 50 max VUs, 4m30s max duration (incl. graceful stop): + * stress: Up to 50 looping VUs for 4m0s over 3 stages (gracefulRampDown: 30s, gracefulStop: 30s) + + +running (0m01.0s), 00/50 VUs, 0 complete and 0 interrupted iterations +stress [ 0% ] 00/50 VUs 0m01.0s/4m00.0s + +running (0m02.0s), 01/50 VUs, 0 complete and 0 interrupted iterations +stress [ 1% ] 01/50 VUs 0m02.0s/4m00.0s + +running (0m03.0s), 02/50 VUs, 0 complete and 0 interrupted iterations +stress [ 1% ] 02/50 VUs 0m03.0s/4m00.0s + +running (0m04.0s), 03/50 VUs, 0 complete and 0 interrupted iterations +stress [ 2% ] 03/50 VUs 0m04.0s/4m00.0s + +running (0m05.0s), 04/50 VUs, 0 complete and 0 interrupted iterations +stress [ 2% ] 04/50 VUs 0m05.0s/4m00.0s + +running (0m06.0s), 04/50 VUs, 0 complete and 0 interrupted iterations +stress [ 2% ] 04/50 VUs 0m06.0s/4m00.0s + +running (0m07.0s), 05/50 VUs, 0 complete and 0 interrupted iterations +stress [ 3% ] 05/50 VUs 0m07.0s/4m00.0s + +running (0m08.0s), 06/50 VUs, 1 complete and 0 interrupted iterations +stress [ 3% ] 06/50 VUs 0m08.0s/4m00.0s + +running (0m09.0s), 07/50 VUs, 2 complete and 0 interrupted iterations +stress [ 4% ] 07/50 VUs 0m09.0s/4m00.0s + +running (0m10.0s), 08/50 VUs, 3 complete and 0 interrupted iterations +stress [ 4% ] 08/50 VUs 0m10.0s/4m00.0s + +running (0m11.0s), 09/50 VUs, 4 complete and 0 interrupted iterations +stress [ 5% ] 09/50 VUs 0m11.0s/4m00.0s + +running (0m12.0s), 09/50 VUs, 6 complete and 0 interrupted iterations +stress [ 5% ] 09/50 VUs 0m12.0s/4m00.0s + +running (0m13.0s), 10/50 VUs, 7 complete and 0 interrupted iterations +stress [ 5% ] 10/50 VUs 0m13.0s/4m00.0s + +running (0m14.0s), 11/50 VUs, 8 complete and 0 interrupted iterations +stress [ 6% ] 11/50 VUs 0m14.0s/4m00.0s + +running (0m15.0s), 12/50 VUs, 10 complete and 0 interrupted iterations +stress [ 6% ] 12/50 VUs 0m15.0s/4m00.0s + +running (0m16.0s), 13/50 VUs, 11 complete and 0 interrupted iterations +stress [ 7% ] 13/50 VUs 0m16.0s/4m00.0s + +running (0m17.0s), 14/50 VUs, 13 complete and 0 interrupted iterations +stress [ 7% ] 14/50 VUs 0m17.0s/4m00.0s + +running (0m18.0s), 14/50 VUs, 15 complete and 0 interrupted iterations +stress [ 7% ] 14/50 VUs 0m18.0s/4m00.0s + +running (0m19.0s), 15/50 VUs, 18 complete and 0 interrupted iterations +stress [ 8% ] 15/50 VUs 0m19.0s/4m00.0s + +running (0m20.0s), 16/50 VUs, 20 complete and 0 interrupted iterations +stress [ 8% ] 16/50 VUs 0m20.0s/4m00.0s + +running (0m21.0s), 17/50 VUs, 22 complete and 0 interrupted iterations +stress [ 9% ] 17/50 VUs 0m21.0s/4m00.0s + +running (0m22.0s), 18/50 VUs, 25 complete and 0 interrupted iterations +stress [ 9% ] 18/50 VUs 0m22.0s/4m00.0s + +running (0m23.0s), 19/50 VUs, 27 complete and 0 interrupted iterations +stress [ 10% ] 19/50 VUs 0m23.0s/4m00.0s + +running (0m24.0s), 19/50 VUs, 32 complete and 0 interrupted iterations +stress [ 10% ] 19/50 VUs 0m24.0s/4m00.0s + +running (0m25.0s), 20/50 VUs, 35 complete and 0 interrupted iterations +stress [ 10% ] 20/50 VUs 0m25.0s/4m00.0s + +running (0m26.0s), 21/50 VUs, 37 complete and 0 interrupted iterations +stress [ 11% ] 21/50 VUs 0m26.0s/4m00.0s + +running (0m27.0s), 22/50 VUs, 41 complete and 0 interrupted iterations +stress [ 11% ] 22/50 VUs 0m27.0s/4m00.0s + +running (0m28.0s), 23/50 VUs, 42 complete and 0 interrupted iterations +stress [ 12% ] 23/50 VUs 0m28.0s/4m00.0s + +running (0m29.0s), 24/50 VUs, 49 complete and 0 interrupted iterations +stress [ 12% ] 24/50 VUs 0m29.0s/4m00.0s + +running (0m30.0s), 24/50 VUs, 52 complete and 0 interrupted iterations +stress [ 12% ] 24/50 VUs 0m30.0s/4m00.0s + +running (0m31.0s), 25/50 VUs, 56 complete and 0 interrupted iterations +stress [ 13% ] 25/50 VUs 0m31.0s/4m00.0s + +running (0m32.0s), 26/50 VUs, 61 complete and 0 interrupted iterations +stress [ 13% ] 26/50 VUs 0m32.0s/4m00.0s + +running (0m33.0s), 27/50 VUs, 64 complete and 0 interrupted iterations +stress [ 14% ] 27/50 VUs 0m33.0s/4m00.0s + +running (0m34.0s), 28/50 VUs, 68 complete and 0 interrupted iterations +stress [ 14% ] 28/50 VUs 0m34.0s/4m00.0s + +running (0m35.0s), 29/50 VUs, 73 complete and 0 interrupted iterations +stress [ 15% ] 29/50 VUs 0m35.0s/4m00.0s + +running (0m36.0s), 29/50 VUs, 79 complete and 0 interrupted iterations +stress [ 15% ] 29/50 VUs 0m36.0s/4m00.0s + +running (0m37.0s), 30/50 VUs, 83 complete and 0 interrupted iterations +stress [ 15% ] 30/50 VUs 0m37.0s/4m00.0s + +running (0m38.0s), 31/50 VUs, 85 complete and 0 interrupted iterations +stress [ 16% ] 31/50 VUs 0m38.0s/4m00.0s + +running (0m39.0s), 32/50 VUs, 85 complete and 0 interrupted iterations +stress [ 16% ] 32/50 VUs 0m39.0s/4m00.0s + +running (0m40.0s), 33/50 VUs, 86 complete and 0 interrupted iterations +stress [ 17% ] 33/50 VUs 0m40.0s/4m00.0s + +running (0m41.0s), 34/50 VUs, 93 complete and 0 interrupted iterations +stress [ 17% ] 34/50 VUs 0m41.0s/4m00.0s + +running (0m42.0s), 34/50 VUs, 93 complete and 0 interrupted iterations +stress [ 17% ] 34/50 VUs 0m42.0s/4m00.0s + +running (0m43.0s), 35/50 VUs, 104 complete and 0 interrupted iterations +stress [ 18% ] 35/50 VUs 0m43.0s/4m00.0s + +running (0m44.0s), 36/50 VUs, 112 complete and 0 interrupted iterations +stress [ 18% ] 36/50 VUs 0m44.0s/4m00.0s + +running (0m45.0s), 37/50 VUs, 115 complete and 0 interrupted iterations +stress [ 19% ] 37/50 VUs 0m45.0s/4m00.0s + +running (0m46.0s), 38/50 VUs, 121 complete and 0 interrupted iterations +stress [ 19% ] 38/50 VUs 0m46.0s/4m00.0s + +running (0m47.0s), 39/50 VUs, 126 complete and 0 interrupted iterations +stress [ 20% ] 39/50 VUs 0m47.0s/4m00.0s + +running (0m48.0s), 39/50 VUs, 127 complete and 0 interrupted iterations +stress [ 20% ] 39/50 VUs 0m48.0s/4m00.0s + +running (0m49.0s), 40/50 VUs, 128 complete and 0 interrupted iterations +stress [ 20% ] 40/50 VUs 0m49.0s/4m00.0s + +running (0m50.0s), 41/50 VUs, 129 complete and 0 interrupted iterations +stress [ 21% ] 41/50 VUs 0m50.0s/4m00.0s + +running (0m51.0s), 42/50 VUs, 141 complete and 0 interrupted iterations +stress [ 21% ] 42/50 VUs 0m51.0s/4m00.0s + +running (0m52.0s), 43/50 VUs, 147 complete and 0 interrupted iterations +stress [ 22% ] 43/50 VUs 0m52.0s/4m00.0s + +running (0m53.0s), 44/50 VUs, 151 complete and 0 interrupted iterations +stress [ 22% ] 44/50 VUs 0m53.0s/4m00.0s + +running (0m54.0s), 44/50 VUs, 162 complete and 0 interrupted iterations +stress [ 22% ] 44/50 VUs 0m54.0s/4m00.0s + +running (0m55.0s), 45/50 VUs, 163 complete and 0 interrupted iterations +stress [ 23% ] 45/50 VUs 0m55.0s/4m00.0s + +running (0m56.0s), 46/50 VUs, 165 complete and 0 interrupted iterations +stress [ 23% ] 46/50 VUs 0m56.0s/4m00.0s + +running (0m57.0s), 47/50 VUs, 174 complete and 0 interrupted iterations +stress [ 24% ] 47/50 VUs 0m57.0s/4m00.0s + +running (0m58.0s), 48/50 VUs, 178 complete and 0 interrupted iterations +stress [ 24% ] 48/50 VUs 0m58.0s/4m00.0s + +running (0m59.0s), 49/50 VUs, 180 complete and 0 interrupted iterations +stress [ 25% ] 49/50 VUs 0m59.0s/4m00.0s + +running (1m00.0s), 49/50 VUs, 182 complete and 0 interrupted iterations +stress [ 25% ] 49/50 VUs 1m00.0s/4m00.0s + +running (1m01.0s), 50/50 VUs, 197 complete and 0 interrupted iterations +stress [ 25% ] 50/50 VUs 1m01.0s/4m00.0s + +running (1m02.0s), 50/50 VUs, 197 complete and 0 interrupted iterations +stress [ 26% ] 50/50 VUs 1m02.0s/4m00.0s + +running (1m03.0s), 50/50 VUs, 197 complete and 0 interrupted iterations +stress [ 26% ] 50/50 VUs 1m03.0s/4m00.0s + +running (1m04.0s), 50/50 VUs, 209 complete and 0 interrupted iterations +stress [ 27% ] 50/50 VUs 1m04.0s/4m00.0s + +running (1m05.0s), 50/50 VUs, 213 complete and 0 interrupted iterations +stress [ 27% ] 50/50 VUs 1m05.0s/4m00.0s + +running (1m06.0s), 50/50 VUs, 213 complete and 0 interrupted iterations +stress [ 27% ] 50/50 VUs 1m06.0s/4m00.0s + +running (1m07.0s), 50/50 VUs, 230 complete and 0 interrupted iterations +stress [ 28% ] 50/50 VUs 1m07.0s/4m00.0s + +running (1m08.0s), 50/50 VUs, 231 complete and 0 interrupted iterations +stress [ 28% ] 50/50 VUs 1m08.0s/4m00.0s + +running (1m09.0s), 50/50 VUs, 238 complete and 0 interrupted iterations +stress [ 29% ] 50/50 VUs 1m09.0s/4m00.0s + +running (1m10.0s), 50/50 VUs, 239 complete and 0 interrupted iterations +stress [ 29% ] 50/50 VUs 1m10.0s/4m00.0s + +running (1m11.0s), 50/50 VUs, 245 complete and 0 interrupted iterations +stress [ 30% ] 50/50 VUs 1m11.0s/4m00.0s + +running (1m12.0s), 50/50 VUs, 249 complete and 0 interrupted iterations +stress [ 30% ] 50/50 VUs 1m12.0s/4m00.0s + +running (1m13.0s), 50/50 VUs, 249 complete and 0 interrupted iterations +stress [ 30% ] 50/50 VUs 1m13.0s/4m00.0s + +running (1m14.0s), 50/50 VUs, 267 complete and 0 interrupted iterations +stress [ 31% ] 50/50 VUs 1m14.0s/4m00.0s + +running (1m15.0s), 50/50 VUs, 282 complete and 0 interrupted iterations +stress [ 31% ] 50/50 VUs 1m15.0s/4m00.0s + +running (1m16.0s), 50/50 VUs, 288 complete and 0 interrupted iterations +stress [ 32% ] 50/50 VUs 1m16.0s/4m00.0s + +running (1m17.0s), 50/50 VUs, 297 complete and 0 interrupted iterations +stress [ 32% ] 50/50 VUs 1m17.0s/4m00.0s + +running (1m18.0s), 50/50 VUs, 299 complete and 0 interrupted iterations +stress [ 32% ] 50/50 VUs 1m18.0s/4m00.0s + +running (1m19.0s), 50/50 VUs, 309 complete and 0 interrupted iterations +stress [ 33% ] 50/50 VUs 1m19.0s/4m00.0s + +running (1m20.0s), 50/50 VUs, 322 complete and 0 interrupted iterations +stress [ 33% ] 50/50 VUs 1m20.0s/4m00.0s + +running (1m21.0s), 50/50 VUs, 322 complete and 0 interrupted iterations +stress [ 34% ] 50/50 VUs 1m21.0s/4m00.0s + +running (1m22.0s), 50/50 VUs, 324 complete and 0 interrupted iterations +stress [ 34% ] 50/50 VUs 1m22.0s/4m00.0s + +running (1m23.0s), 50/50 VUs, 324 complete and 0 interrupted iterations +stress [ 35% ] 50/50 VUs 1m23.0s/4m00.0s + +running (1m24.0s), 50/50 VUs, 336 complete and 0 interrupted iterations +stress [ 35% ] 50/50 VUs 1m24.0s/4m00.0s + +running (1m25.0s), 50/50 VUs, 343 complete and 0 interrupted iterations +stress [ 35% ] 50/50 VUs 1m25.0s/4m00.0s + +running (1m26.0s), 50/50 VUs, 351 complete and 0 interrupted iterations +stress [ 36% ] 50/50 VUs 1m26.0s/4m00.0s + +running (1m27.0s), 50/50 VUs, 355 complete and 0 interrupted iterations +stress [ 36% ] 50/50 VUs 1m27.0s/4m00.0s + +running (1m28.0s), 50/50 VUs, 369 complete and 0 interrupted iterations +stress [ 37% ] 50/50 VUs 1m28.0s/4m00.0s + +running (1m29.0s), 50/50 VUs, 373 complete and 0 interrupted iterations +stress [ 37% ] 50/50 VUs 1m29.0s/4m00.0s + +running (1m30.0s), 50/50 VUs, 373 complete and 0 interrupted iterations +stress [ 37% ] 50/50 VUs 1m30.0s/4m00.0s + +running (1m31.0s), 50/50 VUs, 375 complete and 0 interrupted iterations +stress [ 38% ] 50/50 VUs 1m31.0s/4m00.0s + +running (1m32.0s), 50/50 VUs, 377 complete and 0 interrupted iterations +stress [ 38% ] 50/50 VUs 1m32.0s/4m00.0s + +running (1m33.0s), 50/50 VUs, 392 complete and 0 interrupted iterations +stress [ 39% ] 50/50 VUs 1m33.0s/4m00.0s + +running (1m34.0s), 50/50 VUs, 411 complete and 0 interrupted iterations +stress [ 39% ] 50/50 VUs 1m34.0s/4m00.0s + +running (1m35.0s), 50/50 VUs, 423 complete and 0 interrupted iterations +stress [ 40% ] 50/50 VUs 1m35.0s/4m00.0s + +running (1m36.0s), 50/50 VUs, 423 complete and 0 interrupted iterations +stress [ 40% ] 50/50 VUs 1m36.0s/4m00.0s + +running (1m37.0s), 50/50 VUs, 423 complete and 0 interrupted iterations +stress [ 40% ] 50/50 VUs 1m37.0s/4m00.0s + +running (1m38.0s), 50/50 VUs, 423 complete and 0 interrupted iterations +stress [ 41% ] 50/50 VUs 1m38.0s/4m00.0s + +running (1m39.0s), 50/50 VUs, 423 complete and 0 interrupted iterations +stress [ 41% ] 50/50 VUs 1m39.0s/4m00.0s + +running (1m40.0s), 50/50 VUs, 427 complete and 0 interrupted iterations +stress [ 42% ] 50/50 VUs 1m40.0s/4m00.0s + +running (1m41.0s), 50/50 VUs, 440 complete and 0 interrupted iterations +stress [ 42% ] 50/50 VUs 1m41.0s/4m00.0s + +running (1m42.0s), 50/50 VUs, 449 complete and 0 interrupted iterations +stress [ 42% ] 50/50 VUs 1m42.0s/4m00.0s + +running (1m43.0s), 50/50 VUs, 453 complete and 0 interrupted iterations +stress [ 43% ] 50/50 VUs 1m43.0s/4m00.0s + +running (1m44.0s), 50/50 VUs, 464 complete and 0 interrupted iterations +stress [ 43% ] 50/50 VUs 1m44.0s/4m00.0s + +running (1m45.0s), 50/50 VUs, 473 complete and 0 interrupted iterations +stress [ 44% ] 50/50 VUs 1m45.0s/4m00.0s + +running (1m46.0s), 50/50 VUs, 473 complete and 0 interrupted iterations +stress [ 44% ] 50/50 VUs 1m46.0s/4m00.0s + +running (1m47.0s), 50/50 VUs, 473 complete and 0 interrupted iterations +stress [ 45% ] 50/50 VUs 1m47.0s/4m00.0s + +running (1m48.0s), 50/50 VUs, 474 complete and 0 interrupted iterations +stress [ 45% ] 50/50 VUs 1m48.0s/4m00.0s + +running (1m49.0s), 50/50 VUs, 479 complete and 0 interrupted iterations +stress [ 45% ] 50/50 VUs 1m49.0s/4m00.0s + +running (1m50.0s), 50/50 VUs, 486 complete and 0 interrupted iterations +stress [ 46% ] 50/50 VUs 1m50.0s/4m00.0s + +running (1m51.0s), 50/50 VUs, 504 complete and 0 interrupted iterations +stress [ 46% ] 50/50 VUs 1m51.0s/4m00.0s + +running (1m52.0s), 50/50 VUs, 517 complete and 0 interrupted iterations +stress [ 47% ] 50/50 VUs 1m52.0s/4m00.0s + +running (1m53.0s), 50/50 VUs, 522 complete and 0 interrupted iterations +stress [ 47% ] 50/50 VUs 1m53.0s/4m00.0s + +running (1m54.0s), 50/50 VUs, 523 complete and 0 interrupted iterations +stress [ 47% ] 50/50 VUs 1m54.0s/4m00.0s + +running (1m55.0s), 50/50 VUs, 524 complete and 0 interrupted iterations +stress [ 48% ] 50/50 VUs 1m55.0s/4m00.0s + +running (1m56.0s), 50/50 VUs, 525 complete and 0 interrupted iterations +stress [ 48% ] 50/50 VUs 1m56.0s/4m00.0s + +running (1m57.0s), 50/50 VUs, 533 complete and 0 interrupted iterations +stress [ 49% ] 50/50 VUs 1m57.0s/4m00.0s + +running (1m58.0s), 50/50 VUs, 548 complete and 0 interrupted iterations +stress [ 49% ] 50/50 VUs 1m58.0s/4m00.0s + +running (1m59.0s), 50/50 VUs, 552 complete and 0 interrupted iterations +stress [ 50% ] 50/50 VUs 1m59.0s/4m00.0s + +running (2m00.0s), 50/50 VUs, 553 complete and 0 interrupted iterations +stress [ 50% ] 50/50 VUs 2m00.0s/4m00.0s + +running (2m01.0s), 50/50 VUs, 564 complete and 0 interrupted iterations +stress [ 50% ] 50/50 VUs 2m01.0s/4m00.0s + +running (2m02.0s), 50/50 VUs, 573 complete and 0 interrupted iterations +stress [ 51% ] 50/50 VUs 2m02.0s/4m00.0s + +running (2m03.0s), 50/50 VUs, 573 complete and 0 interrupted iterations +stress [ 51% ] 50/50 VUs 2m03.0s/4m00.0s + +running (2m04.0s), 50/50 VUs, 573 complete and 0 interrupted iterations +stress [ 52% ] 50/50 VUs 2m04.0s/4m00.0s + +running (2m05.0s), 50/50 VUs, 588 complete and 0 interrupted iterations +stress [ 52% ] 50/50 VUs 2m05.0s/4m00.0s + +running (2m06.0s), 50/50 VUs, 589 complete and 0 interrupted iterations +stress [ 52% ] 50/50 VUs 2m06.0s/4m00.0s + +running (2m07.0s), 50/50 VUs, 590 complete and 0 interrupted iterations +stress [ 53% ] 50/50 VUs 2m07.0s/4m00.0s + +running (2m08.0s), 50/50 VUs, 593 complete and 0 interrupted iterations +stress [ 53% ] 50/50 VUs 2m08.0s/4m00.0s + +running (2m09.0s), 50/50 VUs, 608 complete and 0 interrupted iterations +stress [ 54% ] 50/50 VUs 2m09.0s/4m00.0s + +running (2m10.0s), 50/50 VUs, 615 complete and 0 interrupted iterations +stress [ 54% ] 50/50 VUs 2m10.0s/4m00.0s + +running (2m11.0s), 50/50 VUs, 620 complete and 0 interrupted iterations +stress [ 55% ] 50/50 VUs 2m11.0s/4m00.0s + +running (2m12.0s), 50/50 VUs, 634 complete and 0 interrupted iterations +stress [ 55% ] 50/50 VUs 2m12.0s/4m00.0s + +running (2m13.0s), 50/50 VUs, 640 complete and 0 interrupted iterations +stress [ 55% ] 50/50 VUs 2m13.0s/4m00.0s + +running (2m14.0s), 50/50 VUs, 645 complete and 0 interrupted iterations +stress [ 56% ] 50/50 VUs 2m14.0s/4m00.0s + +running (2m15.0s), 50/50 VUs, 660 complete and 0 interrupted iterations +stress [ 56% ] 50/50 VUs 2m15.0s/4m00.0s + +running (2m16.0s), 50/50 VUs, 662 complete and 0 interrupted iterations +stress [ 57% ] 50/50 VUs 2m16.0s/4m00.0s + +running (2m17.0s), 50/50 VUs, 665 complete and 0 interrupted iterations +stress [ 57% ] 50/50 VUs 2m17.0s/4m00.0s + +running (2m18.0s), 50/50 VUs, 665 complete and 0 interrupted iterations +stress [ 57% ] 50/50 VUs 2m18.0s/4m00.0s + +running (2m19.0s), 50/50 VUs, 670 complete and 0 interrupted iterations +stress [ 58% ] 50/50 VUs 2m19.0s/4m00.0s + +running (2m20.0s), 50/50 VUs, 678 complete and 0 interrupted iterations +stress [ 58% ] 50/50 VUs 2m20.0s/4m00.0s + +running (2m21.0s), 50/50 VUs, 678 complete and 0 interrupted iterations +stress [ 59% ] 50/50 VUs 2m21.0s/4m00.0s + +running (2m22.0s), 50/50 VUs, 686 complete and 0 interrupted iterations +stress [ 59% ] 50/50 VUs 2m22.0s/4m00.0s + +running (2m23.0s), 50/50 VUs, 700 complete and 0 interrupted iterations +stress [ 60% ] 50/50 VUs 2m23.0s/4m00.0s + +running (2m24.0s), 50/50 VUs, 714 complete and 0 interrupted iterations +stress [ 60% ] 50/50 VUs 2m24.0s/4m00.0s + +running (2m25.0s), 50/50 VUs, 715 complete and 0 interrupted iterations +stress [ 60% ] 50/50 VUs 2m25.0s/4m00.0s + +running (2m26.0s), 50/50 VUs, 722 complete and 0 interrupted iterations +stress [ 61% ] 50/50 VUs 2m26.0s/4m00.0s + +running (2m27.0s), 50/50 VUs, 728 complete and 0 interrupted iterations +stress [ 61% ] 50/50 VUs 2m27.0s/4m00.0s + +running (2m28.0s), 50/50 VUs, 728 complete and 0 interrupted iterations +stress [ 62% ] 50/50 VUs 2m28.0s/4m00.0s + +running (2m29.0s), 50/50 VUs, 728 complete and 0 interrupted iterations +stress [ 62% ] 50/50 VUs 2m29.0s/4m00.0s + +running (2m30.0s), 50/50 VUs, 738 complete and 0 interrupted iterations +stress [ 62% ] 50/50 VUs 2m30.0s/4m00.0s + +running (2m31.0s), 50/50 VUs, 747 complete and 0 interrupted iterations +stress [ 63% ] 50/50 VUs 2m31.0s/4m00.0s + +running (2m32.0s), 50/50 VUs, 748 complete and 0 interrupted iterations +stress [ 63% ] 50/50 VUs 2m32.0s/4m00.0s + +running (2m33.0s), 50/50 VUs, 773 complete and 0 interrupted iterations +stress [ 64% ] 50/50 VUs 2m33.0s/4m00.0s + +running (2m34.0s), 50/50 VUs, 774 complete and 0 interrupted iterations +stress [ 64% ] 50/50 VUs 2m34.0s/4m00.0s + +running (2m35.0s), 50/50 VUs, 778 complete and 0 interrupted iterations +stress [ 65% ] 50/50 VUs 2m35.0s/4m00.0s + +running (2m36.0s), 50/50 VUs, 790 complete and 0 interrupted iterations +stress [ 65% ] 50/50 VUs 2m36.0s/4m00.0s + +running (2m37.0s), 50/50 VUs, 793 complete and 0 interrupted iterations +stress [ 65% ] 50/50 VUs 2m37.0s/4m00.0s + +running (2m38.0s), 50/50 VUs, 793 complete and 0 interrupted iterations +stress [ 66% ] 50/50 VUs 2m38.0s/4m00.0s + +running (2m39.0s), 50/50 VUs, 798 complete and 0 interrupted iterations +stress [ 66% ] 50/50 VUs 2m39.0s/4m00.0s + +running (2m40.0s), 50/50 VUs, 806 complete and 0 interrupted iterations +stress [ 67% ] 50/50 VUs 2m40.0s/4m00.0s + +running (2m41.0s), 50/50 VUs, 822 complete and 0 interrupted iterations +stress [ 67% ] 50/50 VUs 2m41.0s/4m00.0s + +running (2m42.0s), 50/50 VUs, 840 complete and 0 interrupted iterations +stress [ 67% ] 50/50 VUs 2m42.0s/4m00.0s + +running (2m43.0s), 50/50 VUs, 842 complete and 0 interrupted iterations +stress [ 68% ] 50/50 VUs 2m43.0s/4m00.0s + +running (2m44.0s), 50/50 VUs, 845 complete and 0 interrupted iterations +stress [ 68% ] 50/50 VUs 2m44.0s/4m00.0s + +running (2m45.0s), 50/50 VUs, 849 complete and 0 interrupted iterations +stress [ 69% ] 50/50 VUs 2m45.0s/4m00.0s + +running (2m46.0s), 50/50 VUs, 855 complete and 0 interrupted iterations +stress [ 69% ] 50/50 VUs 2m46.0s/4m00.0s + +running (2m47.0s), 50/50 VUs, 856 complete and 0 interrupted iterations +stress [ 70% ] 50/50 VUs 2m47.0s/4m00.0s + +running (2m48.0s), 50/50 VUs, 861 complete and 0 interrupted iterations +stress [ 70% ] 50/50 VUs 2m48.0s/4m00.0s + +running (2m49.0s), 50/50 VUs, 876 complete and 0 interrupted iterations +stress [ 70% ] 50/50 VUs 2m49.0s/4m00.0s + +running (2m50.0s), 50/50 VUs, 879 complete and 0 interrupted iterations +stress [ 71% ] 50/50 VUs 2m50.0s/4m00.0s + +running (2m51.0s), 50/50 VUs, 879 complete and 0 interrupted iterations +stress [ 71% ] 50/50 VUs 2m51.0s/4m00.0s + +running (2m52.0s), 50/50 VUs, 887 complete and 0 interrupted iterations +stress [ 72% ] 50/50 VUs 2m52.0s/4m00.0s + +running (2m53.0s), 50/50 VUs, 900 complete and 0 interrupted iterations +stress [ 72% ] 50/50 VUs 2m53.0s/4m00.0s + +running (2m54.0s), 50/50 VUs, 900 complete and 0 interrupted iterations +stress [ 72% ] 50/50 VUs 2m54.0s/4m00.0s + +running (2m55.0s), 50/50 VUs, 911 complete and 0 interrupted iterations +stress [ 73% ] 50/50 VUs 2m55.0s/4m00.0s + +running (2m56.0s), 50/50 VUs, 914 complete and 0 interrupted iterations +stress [ 73% ] 50/50 VUs 2m56.0s/4m00.0s + +running (2m57.0s), 50/50 VUs, 919 complete and 0 interrupted iterations +stress [ 74% ] 50/50 VUs 2m57.0s/4m00.0s + +running (2m58.0s), 50/50 VUs, 923 complete and 0 interrupted iterations +stress [ 74% ] 50/50 VUs 2m58.0s/4m00.0s + +running (2m59.0s), 50/50 VUs, 938 complete and 0 interrupted iterations +stress [ 75% ] 50/50 VUs 2m59.0s/4m00.0s + +running (3m00.0s), 50/50 VUs, 943 complete and 0 interrupted iterations +stress [ 75% ] 50/50 VUs 3m00.0s/4m00.0s + +running (3m01.0s), 50/50 VUs, 948 complete and 0 interrupted iterations +stress [ 75% ] 50/50 VUs 3m01.0s/4m00.0s + +running (3m02.0s), 50/50 VUs, 956 complete and 0 interrupted iterations +stress [ 76% ] 50/50 VUs 3m02.0s/4m00.0s + +running (3m03.0s), 50/50 VUs, 959 complete and 0 interrupted iterations +stress [ 76% ] 50/50 VUs 3m03.0s/4m00.0s + +running (3m04.0s), 50/50 VUs, 959 complete and 0 interrupted iterations +stress [ 77% ] 50/50 VUs 3m04.0s/4m00.0s + +running (3m05.0s), 50/50 VUs, 960 complete and 0 interrupted iterations +stress [ 77% ] 50/50 VUs 3m05.0s/4m00.0s + +running (3m06.0s), 48/50 VUs, 973 complete and 0 interrupted iterations +stress [ 77% ] 48/50 VUs 3m06.0s/4m00.0s + +running (3m07.0s), 47/50 VUs, 977 complete and 0 interrupted iterations +stress [ 78% ] 47/50 VUs 3m07.0s/4m00.0s + +running (3m08.0s), 45/50 VUs, 993 complete and 0 interrupted iterations +stress [ 78% ] 45/50 VUs 3m08.0s/4m00.0s + +running (3m09.0s), 45/50 VUs, 1000 complete and 0 interrupted iterations +stress [ 79% ] 45/50 VUs 3m09.0s/4m00.0s + +running (3m10.0s), 44/50 VUs, 1009 complete and 0 interrupted iterations +stress [ 79% ] 44/50 VUs 3m10.0s/4m00.0s + +running (3m11.0s), 44/50 VUs, 1009 complete and 0 interrupted iterations +stress [ 80% ] 44/50 VUs 3m11.0s/4m00.0s + +running (3m12.0s), 44/50 VUs, 1012 complete and 0 interrupted iterations +stress [ 80% ] 44/50 VUs 3m12.0s/4m00.0s + +running (3m13.0s), 44/50 VUs, 1012 complete and 0 interrupted iterations +stress [ 80% ] 44/50 VUs 3m13.0s/4m00.0s + +running (3m14.0s), 43/50 VUs, 1027 complete and 0 interrupted iterations +stress [ 81% ] 43/50 VUs 3m14.0s/4m00.0s + +running (3m15.0s), 43/50 VUs, 1027 complete and 0 interrupted iterations +stress [ 81% ] 43/50 VUs 3m15.0s/4m00.0s + +running (3m16.0s), 42/50 VUs, 1030 complete and 0 interrupted iterations +stress [ 82% ] 42/50 VUs 3m16.0s/4m00.0s + +running (3m17.0s), 40/50 VUs, 1035 complete and 0 interrupted iterations +stress [ 82% ] 40/50 VUs 3m17.0s/4m00.0s + +running (3m18.0s), 39/50 VUs, 1051 complete and 0 interrupted iterations +stress [ 82% ] 39/50 VUs 3m18.0s/4m00.0s + +running (3m19.0s), 39/50 VUs, 1052 complete and 0 interrupted iterations +stress [ 83% ] 39/50 VUs 3m19.0s/4m00.0s + +running (3m20.0s), 39/50 VUs, 1053 complete and 0 interrupted iterations +stress [ 83% ] 39/50 VUs 3m20.0s/4m00.0s + +running (3m21.0s), 39/50 VUs, 1054 complete and 0 interrupted iterations +stress [ 84% ] 39/50 VUs 3m21.0s/4m00.0s + +running (3m22.0s), 37/50 VUs, 1064 complete and 0 interrupted iterations +stress [ 84% ] 37/50 VUs 3m22.0s/4m00.0s + +running (3m23.0s), 35/50 VUs, 1070 complete and 0 interrupted iterations +stress [ 85% ] 35/50 VUs 3m23.0s/4m00.0s + +running (3m24.0s), 33/50 VUs, 1089 complete and 0 interrupted iterations +stress [ 85% ] 33/50 VUs 3m24.0s/4m00.0s + +running (3m25.0s), 32/50 VUs, 1091 complete and 0 interrupted iterations +stress [ 85% ] 32/50 VUs 3m25.0s/4m00.0s + +running (3m26.0s), 31/50 VUs, 1093 complete and 0 interrupted iterations +stress [ 86% ] 31/50 VUs 3m26.0s/4m00.0s + +running (3m27.0s), 30/50 VUs, 1101 complete and 0 interrupted iterations +stress [ 86% ] 30/50 VUs 3m27.0s/4m00.0s + +running (3m28.0s), 30/50 VUs, 1102 complete and 0 interrupted iterations +stress [ 87% ] 30/50 VUs 3m28.0s/4m00.0s + +running (3m29.0s), 30/50 VUs, 1106 complete and 0 interrupted iterations +stress [ 87% ] 30/50 VUs 3m29.0s/4m00.0s + +running (3m30.0s), 29/50 VUs, 1110 complete and 0 interrupted iterations +stress [ 87% ] 29/50 VUs 3m30.0s/4m00.0s + +running (3m31.0s), 26/50 VUs, 1123 complete and 0 interrupted iterations +stress [ 88% ] 26/50 VUs 3m31.0s/4m00.0s + +running (3m32.0s), 26/50 VUs, 1123 complete and 0 interrupted iterations +stress [ 88% ] 26/50 VUs 3m32.0s/4m00.0s + +running (3m33.0s), 25/50 VUs, 1125 complete and 0 interrupted iterations +stress [ 89% ] 25/50 VUs 3m33.0s/4m00.0s + +running (3m34.0s), 24/50 VUs, 1131 complete and 0 interrupted iterations +stress [ 89% ] 24/50 VUs 3m34.0s/4m00.0s + +running (3m35.0s), 24/50 VUs, 1133 complete and 0 interrupted iterations +stress [ 90% ] 24/50 VUs 3m35.0s/4m00.0s + +running (3m36.0s), 23/50 VUs, 1139 complete and 0 interrupted iterations +stress [ 90% ] 23/50 VUs 3m36.0s/4m00.0s + +running (3m37.0s), 23/50 VUs, 1145 complete and 0 interrupted iterations +stress [ 90% ] 23/50 VUs 3m37.0s/4m00.0s + +running (3m38.0s), 23/50 VUs, 1149 complete and 0 interrupted iterations +stress [ 91% ] 23/50 VUs 3m38.0s/4m00.0s + +running (3m39.0s), 21/50 VUs, 1155 complete and 0 interrupted iterations +stress [ 91% ] 21/50 VUs 3m39.0s/4m00.0s + +running (3m40.0s), 20/50 VUs, 1156 complete and 0 interrupted iterations +stress [ 92% ] 20/50 VUs 3m40.0s/4m00.0s + +running (3m41.0s), 18/50 VUs, 1160 complete and 0 interrupted iterations +stress [ 92% ] 18/50 VUs 3m41.0s/4m00.0s + +running (3m42.0s), 17/50 VUs, 1169 complete and 0 interrupted iterations +stress [ 92% ] 17/50 VUs 3m42.0s/4m00.0s + +running (3m43.0s), 17/50 VUs, 1172 complete and 0 interrupted iterations +stress [ 93% ] 17/50 VUs 3m43.0s/4m00.0s + +running (3m44.0s), 16/50 VUs, 1176 complete and 0 interrupted iterations +stress [ 93% ] 16/50 VUs 3m44.0s/4m00.0s + +running (3m45.0s), 16/50 VUs, 1176 complete and 0 interrupted iterations +stress [ 94% ] 16/50 VUs 3m45.0s/4m00.0s + +running (3m46.0s), 16/50 VUs, 1178 complete and 0 interrupted iterations +stress [ 94% ] 16/50 VUs 3m46.0s/4m00.0s + +running (3m47.0s), 14/50 VUs, 1186 complete and 0 interrupted iterations +stress [ 95% ] 14/50 VUs 3m47.0s/4m00.0s + +running (3m48.0s), 14/50 VUs, 1188 complete and 0 interrupted iterations +stress [ 95% ] 14/50 VUs 3m48.0s/4m00.0s + +running (3m49.0s), 13/50 VUs, 1189 complete and 0 interrupted iterations +stress [ 95% ] 13/50 VUs 3m49.0s/4m00.0s + +running (3m50.0s), 11/50 VUs, 1191 complete and 0 interrupted iterations +stress [ 96% ] 11/50 VUs 3m50.0s/4m00.0s + +running (3m51.0s), 10/50 VUs, 1193 complete and 0 interrupted iterations +stress [ 96% ] 10/50 VUs 3m51.0s/4m00.0s + +running (3m52.0s), 07/50 VUs, 1200 complete and 0 interrupted iterations +stress [ 97% ] 07/50 VUs 3m52.0s/4m00.0s + +running (3m53.0s), 07/50 VUs, 1201 complete and 0 interrupted iterations +stress [ 97% ] 07/50 VUs 3m53.0s/4m00.0s + +running (3m54.0s), 07/50 VUs, 1202 complete and 0 interrupted iterations +stress [ 97% ] 07/50 VUs 3m54.0s/4m00.0s + +running (3m55.0s), 07/50 VUs, 1202 complete and 0 interrupted iterations +stress [ 98% ] 07/50 VUs 3m55.0s/4m00.0s + +running (3m56.0s), 06/50 VUs, 1204 complete and 0 interrupted iterations +stress [ 98% ] 06/50 VUs 3m56.0s/4m00.0s + +running (3m57.0s), 05/50 VUs, 1207 complete and 0 interrupted iterations +stress [ 99% ] 05/50 VUs 3m57.0s/4m00.0s + +running (3m58.0s), 04/50 VUs, 1208 complete and 0 interrupted iterations +stress [ 99% ] 04/50 VUs 3m58.0s/4m00.0s + +running (3m59.0s), 03/50 VUs, 1209 complete and 0 interrupted iterations +stress [ 100% ] 03/50 VUs 3m59.0s/4m00.0s + +running (4m00.0s), 03/50 VUs, 1209 complete and 0 interrupted iterations +stress [ 100% ] 03/50 VUs 4m00.0s/4m00.0s + +running (4m01.0s), 02/50 VUs, 1210 complete and 0 interrupted iterations +stress ↓ [ 100% ] 03/50 VUs 4m0s + + + â–ˆ THRESHOLDS + + http_req_failed + ✓ 'rate<0.01' rate=0.00% + + + â–ˆ TOTAL RESULTS + + checks_total.......: 3636 15.039792/s + checks_succeeded...: 100.00% 3636 out of 3636 + checks_failed......: 0.00% 0 out of 3636 + + ✓ subscription plans list is 200 + ✓ my subscription details is 200 + ✓ usage history is 200 + + HTTP + http_req_duration..............: avg=1.16s min=271.23ms med=789.99ms max=10.29s p(90)=2.56s p(95)=2.99s + { expected_response:true }...: avg=1.16s min=271.23ms med=789.99ms max=10.29s p(90)=2.56s p(95)=2.99s + http_req_failed................: 0.00% 0 out of 3686 + http_reqs......................: 3686 15.24661/s + + EXECUTION + iteration_duration.............: avg=7.56s min=4.84s med=7.52s max=18.71s p(90)=9.72s p(95)=10.3s + iterations.....................: 1212 5.013264/s + vus............................: 2 min=0 max=50 + vus_max........................: 50 min=50 max=50 + + NETWORK + data_received..................: 7.1 MB 29 kB/s + data_sent......................: 328 kB 1.4 kB/s + + + + +running (4m01.8s), 00/50 VUs, 1212 complete and 0 interrupted iterations +stress ✓ [ 100% ] 00/50 VUs 4m0s + diff --git a/spt/subscription-spt.js b/spt/subscription-spt.js new file mode 100644 index 0000000..4e67ae6 --- /dev/null +++ b/spt/subscription-spt.js @@ -0,0 +1,92 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +const allScenarios = { + baseline: { + executor: 'constant-vus', + vus: 1, + duration: '1m', + }, + load: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '1m', target: 10 }, + { duration: '3m', target: 10 }, + { duration: '1m', target: 0 }, + ], + }, + stress: { + executor: 'ramping-vus', + startVUs: 0, + stages: [ + { duration: '1m', target: 50 }, + { duration: '2m', target: 50 }, + { duration: '1m', target: 0 }, + ], + }, +}; + +const selectedScenario = __ENV.SCENARIO; + +export const options = { + scenarios: selectedScenario ? { [selectedScenario]: allScenarios[selectedScenario] } : allScenarios, + thresholds: { + http_req_failed: ['rate<0.01'], // Fail rate under 1% + }, +}; + +let token = null; + +export default function () { + const baseUrl = __ENV.BASE_URL || 'https://am-dev.asrax.in'; + const vuId = __VU; + + // Login dynamically via Identity service to authenticate for Subscription actions + if (!token) { + const loginPayload = JSON.stringify({ + username: `spt-user-${vuId}@example.com`, + password: 'SptPassword.1', + }); + const loginRes = http.post(`${baseUrl}/identity/auth/login`, loginPayload, { + headers: { 'Content-Type': 'application/json' }, + }); + + if (loginRes.status === 200) { + token = loginRes.json().access_token; + } else { + console.error(`VU ${vuId} failed to log in: ${loginRes.status}`); + sleep(1); + return; + } + } + + const headers = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}`, + }; + + // 1. Fetch Subscription Plans + const plansRes = http.get(`${baseUrl}/subscriptions/plans`, { headers }); + check(plansRes, { + 'subscription plans list is 200': (r) => r.status === 200, + }); + + sleep(1); + + // 2. Fetch User Active Subscription Details + const mySubRes = http.get(`${baseUrl}/subscriptions/me`, { headers }); + check(mySubRes, { + 'my subscription details is 200': (r) => r.status === 200, + }); + + sleep(1); + + // 3. Fetch Subscription Usage History + const usageRes = http.get(`${baseUrl}/subscriptions/usage/history`, { headers }); + check(usageRes, { + 'usage history is 200': (r) => r.status === 200, + }); + + sleep(2); +}