diff --git a/.claude/commands/ci.md b/.claude/commands/ci.md new file mode 100644 index 00000000..57ffade7 --- /dev/null +++ b/.claude/commands/ci.md @@ -0,0 +1,37 @@ +Run all CI checks locally, mirroring the GitHub Actions CI pipeline. This catches issues before creating a PR. + +Execute these steps in order, stopping on first failure. Report results as a summary table at the end. + +## Step 1: Lint & Format Check + +Run `npm run check` from the project root. This runs biome lint, format check, page validation, and i18n validation. + +## Step 2: Frontend Build + +Run `npm run build` from the project root. This builds all module frontend assets via Vite in production mode. + +## Step 3: .NET Build + +Run `dotnet build` from the project root. This compiles all .NET projects including source generators. + +## Step 4: .NET Tests + +Run `dotnet test --no-build` from the project root. This runs all unit and integration tests (excluding load tests which are filtered out in CI). + +## Step 5: E2E Smoke Tests + +Run `npm run test:smoke -w tests/e2e` to execute Playwright smoke tests. Playwright browsers are already installed. + +## Reporting + +After all steps complete (or on first failure), print a summary table: + +| Step | Status | +|------|--------| +| Lint & Format | pass/fail | +| Frontend Build | pass/fail | +| .NET Build | pass/fail | +| .NET Tests | pass/fail | +| E2E Smoke Tests | pass/fail | + +If any step fails, show the relevant error output and suggest a fix. diff --git a/.gitignore b/.gitignore index d8fc9043..31527119 100644 --- a/.gitignore +++ b/.gitignore @@ -427,5 +427,8 @@ template/SimpleModule.Host/Styles/_scan/ *.stamp +# k6 load test output +tests/k6/results/ + # Website build output website/dist/ diff --git a/Makefile b/Makefile index 2d069ce7..e406833e 100644 --- a/Makefile +++ b/Makefile @@ -112,6 +112,90 @@ test-e2e-report: ## View Playwright HTML test report .PHONY: test-all test-all: test test-e2e ## Run all .NET and e2e tests +# ─── Load Testing (k6) ────────────────────────── + +K6_DIR := tests/k6 +K6 := k6 run --compatibility-mode=experimental_enhanced + +.PHONY: k6-smoke +k6-smoke: ## Run k6 smoke test (health endpoints) + $(K6) $(K6_DIR)/scenarios/health.ts + +.PHONY: k6-auth +k6-auth: ## Run k6 auth load test + $(K6) $(K6_DIR)/scenarios/auth.ts + +.PHONY: k6-products +k6-products: ## Run k6 products CRUD load test + K6_PROFILE=load $(K6) $(K6_DIR)/scenarios/products.ts + +.PHONY: k6-orders +k6-orders: ## Run k6 orders CRUD load test + K6_PROFILE=load $(K6) $(K6_DIR)/scenarios/orders.ts + +.PHONY: k6-pages +k6-pages: ## Run k6 page builder load test + K6_PROFILE=load $(K6) $(K6_DIR)/scenarios/pages.ts + +.PHONY: k6-page-lifecycle +k6-page-lifecycle: ## Run k6 full page lifecycle test (publish, tags, templates) + $(K6) $(K6_DIR)/scenarios/page-lifecycle.ts + +.PHONY: k6-settings +k6-settings: ## Run k6 settings and menu management load test + $(K6) $(K6_DIR)/scenarios/settings.ts + +.PHONY: k6-users +k6-users: ## Run k6 user management CRUD load test + $(K6) $(K6_DIR)/scenarios/users.ts + +.PHONY: k6-audit-logs +k6-audit-logs: ## Run k6 audit logs query, stats, and export test + $(K6) $(K6_DIR)/scenarios/audit-logs.ts + +.PHONY: k6-files +k6-files: ## Run k6 file upload/download load test + $(K6) $(K6_DIR)/scenarios/file-storage.ts + +.PHONY: k6-marketplace +k6-marketplace: ## Run k6 marketplace API load test (anonymous) + $(K6) $(K6_DIR)/scenarios/marketplace.ts + +.PHONY: k6-jobs +k6-jobs: ## Run k6 background jobs load test + $(K6) $(K6_DIR)/scenarios/background-jobs.ts + +.PHONY: k6-feature-flags +k6-feature-flags: ## Run k6 feature flags CRUD load test + $(K6) $(K6_DIR)/scenarios/feature-flags.ts + +.PHONY: k6-tenants +k6-tenants: ## Run k6 tenants CRUD load test + $(K6) $(K6_DIR)/scenarios/tenants.ts + +.PHONY: k6-mixed +k6-mixed: ## Run k6 mixed traffic load test (realistic simulation) + $(K6) $(K6_DIR)/scenarios/mixed.ts + +.PHONY: k6-hotspots +k6-hotspots: ## Run k6 hotspot detection (all endpoints, sorted by latency) + @mkdir -p $(K6_DIR)/results + $(K6) $(K6_DIR)/scenarios/hotspots.ts + +.PHONY: k6-stress +k6-stress: ## Run k6 stress test (mixed scenario, high load) + K6_PROFILE=stress $(K6) $(K6_DIR)/scenarios/mixed.ts + +.PHONY: k6-spike +k6-spike: ## Run k6 spike test (sudden traffic burst) + K6_PROFILE=spike $(K6) $(K6_DIR)/scenarios/mixed.ts + +.PHONY: k6-all +k6-all: k6-smoke k6-auth k6-products k6-orders k6-pages k6-page-lifecycle k6-settings k6-users k6-audit-logs k6-files k6-marketplace k6-jobs k6-feature-flags k6-tenants k6-mixed ## Run all k6 load test scenarios + +.PHONY: load +load: k6-all ## Alias: run all k6 load tests + # ─── Database ──────────────────────────────────── .PHONY: db-reset @@ -237,7 +321,7 @@ endef help: ## Show this help @printf "$(HELP_HEADER)" @awk ' \ - /^# .+ Setup|^# .+ Build|^# .+ Run|^# .+ Test|^# .+ Database|^# .+ Code Qual|^# .+ Code Gen|^# .+ CI|^# .+ Docker|^# .+ Clean|^# .+ Help/ { \ + /^# .+ Setup|^# .+ Build|^# .+ Run|^# .+ Test|^# .+ Load Test|^# .+ Database|^# .+ Code Qual|^# .+ Code Gen|^# .+ CI|^# .+ Docker|^# .+ Clean|^# .+ Help/ { \ section = $$0; \ gsub(/^# [^A-Z]*/, "", section); \ gsub(/ [^A-Z]*$$/, "", section); \ diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/TokenEndpoint.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/TokenEndpoint.cs new file mode 100644 index 00000000..0312ed62 --- /dev/null +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Endpoints/Connect/TokenEndpoint.cs @@ -0,0 +1,139 @@ +using System.Security.Claims; +using Microsoft.AspNetCore; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using OpenIddict.Abstractions; +using OpenIddict.Server.AspNetCore; +using SimpleModule.Core; +using SimpleModule.OpenIddict.Contracts; +using SimpleModule.Permissions.Contracts; +using SimpleModule.Users.Contracts; +using static OpenIddict.Abstractions.OpenIddictConstants; + +namespace SimpleModule.OpenIddict.Endpoints.Connect; + +public class TokenEndpoint : IEndpoint +{ + public void Map(IEndpointRouteBuilder app) + { + app.MapPost(ConnectRouteConstants.ConnectToken, (Delegate)HandleAsync) + .ExcludeFromDescription() + .AllowAnonymous(); + } + + private static async Task HandleAsync(HttpContext context) + { + var request = + context.GetOpenIddictServerRequest() + ?? throw new InvalidOperationException(AuthErrorMessages.OpenIdConnectRequestMissing); + + if (!request.IsPasswordGrantType()) + { + // Let OpenIddict handle non-password grants (auth code, refresh token) + return Results.Empty; + } + + var userManager = context.RequestServices.GetRequiredService< + UserManager + >(); + + var user = await userManager.FindByEmailAsync(request.Username!); + if (user is null || !await userManager.CheckPasswordAsync(user, request.Password!)) + { + return Results.Forbid( + authenticationSchemes: [OpenIddictServerAspNetCoreDefaults.AuthenticationScheme] + ); + } + + var identity = new ClaimsIdentity(OpenIddictServerAspNetCoreDefaults.AuthenticationScheme); + + var userIdString = await userManager.GetUserIdAsync(user); + identity + .SetClaim(Claims.Subject, userIdString) + .SetClaim(Claims.Email, await userManager.GetEmailAsync(user) ?? string.Empty) + .SetClaim(Claims.Name, user.DisplayName); + + var roles = await userManager.GetRolesAsync(user); + foreach (var role in roles) + { + identity.AddClaim(Claims.Role, role); + } + + // Load permissions + var permissionContracts = + context.RequestServices.GetRequiredService(); + var userContracts = context.RequestServices.GetRequiredService(); + var userId = UserId.From(userIdString); + + var roleIdMap = await userContracts.GetRoleIdsByNamesAsync(roles); + + var allPermissions = await permissionContracts.GetAllPermissionsForUserAsync( + userId, + roleIdMap.Values.Select(id => RoleId.From(id)) + ); + + foreach (var permission in allPermissions) + { + identity.AddClaim("permission", permission); + } + + identity.SetScopes( + Scopes.OpenId, + Scopes.Profile, + Scopes.Email, + AuthConstants.RolesScope + ); + + foreach (var claim in identity.Claims) + { + claim.SetDestinations(GetDestinations(claim, identity)); + } + + var principal = new ClaimsPrincipal(identity); + + return Results.SignIn( + principal, + authenticationScheme: OpenIddictServerAspNetCoreDefaults.AuthenticationScheme + ); + } + + private static IEnumerable GetDestinations(Claim claim, ClaimsIdentity identity) + { + switch (claim.Type) + { + case Claims.Name: + yield return Destinations.AccessToken; + if (identity.HasScope(Scopes.Profile)) + yield return Destinations.IdentityToken; + yield break; + + case Claims.Email: + yield return Destinations.AccessToken; + if (identity.HasScope(Scopes.Email)) + yield return Destinations.IdentityToken; + yield break; + + case Claims.Role: + yield return Destinations.AccessToken; + if (identity.HasScope(AuthConstants.RolesScope)) + yield return Destinations.IdentityToken; + yield break; + + case Claims.Subject: + yield return Destinations.AccessToken; + yield return Destinations.IdentityToken; + yield break; + + case "permission": + yield return Destinations.AccessToken; + yield break; + + default: + yield return Destinations.AccessToken; + yield break; + } + } +} diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs index 70ed8cb5..905bd863 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/OpenIddictModule.cs @@ -42,6 +42,12 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config options.AllowRefreshTokenFlow(); + // Enable password grant in Development for load testing (k6, etc.) + if (configuration.GetValue("OpenIddict:AllowPasswordGrant")) + { + options.AllowPasswordFlow(); + } + options .SetAuthorizationEndpointUris(ConnectRouteConstants.ConnectAuthorize) .SetTokenEndpointUris(ConnectRouteConstants.ConnectToken) @@ -87,6 +93,7 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config options .UseAspNetCore() .EnableAuthorizationEndpointPassthrough() + .EnableTokenEndpointPassthrough() .EnableEndSessionEndpointPassthrough() .EnableUserInfoEndpointPassthrough(); }) diff --git a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSeedService.cs b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSeedService.cs index df725417..3d1c9ac7 100644 --- a/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSeedService.cs +++ b/modules/OpenIddict/src/SimpleModule.OpenIddict/Services/OpenIddictSeedService.cs @@ -79,6 +79,14 @@ is not null Requirements = { OpenIddictConstants.Requirements.Features.ProofKeyForCodeExchange }, }; + // Allow password grant in Development for load testing (k6, etc.) + if (configuration.GetValue("OpenIddict:AllowPasswordGrant")) + { + descriptor.Permissions.Add( + OpenIddictConstants.Permissions.GrantTypes.Password + ); + } + // Allow additional redirect URIs from configuration var additionalRedirects = configuration .GetSection(ConfigKeys.OpenIddictAdditionalRedirectUris) diff --git a/package-lock.json b/package-lock.json index 3e959cd3..aa64ad8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "packages/SimpleModule.UI", "template/SimpleModule.Host/ClientApp", "tests/e2e", + "tests/k6", "docs/site", "website" ], @@ -4087,6 +4088,10 @@ "resolved": "modules/FileStorage/src/SimpleModule.FileStorage", "link": true }, + "node_modules/@simplemodule/k6": { + "resolved": "tests/k6", + "link": true + }, "node_modules/@simplemodule/marketplace": { "resolved": "modules/Marketplace/src/SimpleModule.Marketplace", "link": true @@ -4915,6 +4920,13 @@ "@types/unist": "*" } }, + "node_modules/@types/k6": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@types/k6/-/k6-1.7.0.tgz", + "integrity": "sha512-oL4mckVcOPIA2HUrCVj3aQXCJgCqsQe35Uc4fRTffmrQuR24v92GJImnagqUaRnC1TQVJFx85o3aHQPP+0bxpg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -10089,6 +10101,13 @@ "@playwright/test": "^1.58.2" } }, + "tests/k6": { + "name": "@simplemodule/k6", + "version": "0.0.0", + "devDependencies": { + "@types/k6": "^1.0.0" + } + }, "website": {} } } diff --git a/package.json b/package.json index e97616de..cd530a9c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "packages/SimpleModule.UI", "template/SimpleModule.Host/ClientApp", "tests/e2e", + "tests/k6", "docs/site", "website" ], diff --git a/template/SimpleModule.Host/appsettings.Development.json b/template/SimpleModule.Host/appsettings.Development.json index ab144075..05def9a0 100644 --- a/template/SimpleModule.Host/appsettings.Development.json +++ b/template/SimpleModule.Host/appsettings.Development.json @@ -2,6 +2,9 @@ "Database": { "DefaultConnection": "Data Source=app.db" }, + "OpenIddict": { + "AllowPasswordGrant": true + }, "Logging": { "LogLevel": { "Microsoft.EntityFrameworkCore.Database.Command": "Information" diff --git a/tests/k6/lib/auth.ts b/tests/k6/lib/auth.ts new file mode 100644 index 00000000..61c8254b --- /dev/null +++ b/tests/k6/lib/auth.ts @@ -0,0 +1,55 @@ +import { check, fail } from 'k6'; +import http from 'k6/http'; +import { config } from './config.ts'; + +export interface AuthResult { + accessToken: string; + refreshToken: string; + expiresIn: number; +} + +export function authenticate(username?: string, password?: string): AuthResult { + const res = http.post( + `${config.baseUrl}${config.tokenEndpoint}`, + { + grant_type: 'password', + client_id: config.clientId, + username: username || config.username, + password: password || config.password, + scope: 'openid profile email roles', + }, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + tags: { name: 'auth' }, + }, + ); + + const success = check(res, { + 'auth: status 200': (r) => r.status === 200, + 'auth: has access_token': (r) => { + try { + return JSON.parse(r.body as string).access_token !== undefined; + } catch { + return false; + } + }, + }); + + if (!success) { + fail(`Authentication failed: ${res.status} ${res.body}`); + } + + const body = JSON.parse(res.body as string); + return { + accessToken: body.access_token, + refreshToken: body.refresh_token, + expiresIn: body.expires_in, + }; +} + +export function authHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }; +} diff --git a/tests/k6/lib/config.ts b/tests/k6/lib/config.ts new file mode 100644 index 00000000..9fec2372 --- /dev/null +++ b/tests/k6/lib/config.ts @@ -0,0 +1,79 @@ +import type { Options } from 'k6/options'; + +interface AppConfig { + baseUrl: string; + clientId: string; + username: string; + password: string; + tokenEndpoint: string; +} + +export const config: AppConfig = { + baseUrl: __ENV.K6_BASE_URL || 'https://localhost:5001', + clientId: __ENV.K6_CLIENT_ID || 'simplemodule-client', + username: __ENV.K6_USERNAME || 'admin@simplemodule.dev', + password: __ENV.K6_PASSWORD || 'Admin123!', + tokenEndpoint: '/connect/token', +}; + +export const tlsOptions: Pick = { + insecureSkipTLSVerify: true, +}; + +export const defaultThresholds: Options['thresholds'] = { + http_req_duration: ['p(95)<500', 'p(99)<1500'], + http_req_failed: ['rate<0.01'], +}; + +interface LoadStage { + duration: string; + target: number; +} + +interface LoadProfile { + stages: LoadStage[]; +} + +export const loadProfiles: Record = { + smoke: { + stages: [ + { duration: '30s', target: 1 }, + { duration: '1m', target: 1 }, + { duration: '10s', target: 0 }, + ], + }, + load: { + stages: [ + { duration: '1m', target: 10 }, + { duration: '3m', target: 10 }, + { duration: '1m', target: 20 }, + { duration: '3m', target: 20 }, + { duration: '2m', target: 0 }, + ], + }, + stress: { + stages: [ + { duration: '1m', target: 20 }, + { duration: '2m', target: 50 }, + { duration: '2m', target: 100 }, + { duration: '2m', target: 100 }, + { duration: '2m', target: 0 }, + ], + }, + spike: { + stages: [ + { duration: '30s', target: 5 }, + { duration: '10s', target: 100 }, + { duration: '30s', target: 100 }, + { duration: '10s', target: 5 }, + { duration: '1m', target: 0 }, + ], + }, + soak: { + stages: [ + { duration: '2m', target: 20 }, + { duration: '30m', target: 20 }, + { duration: '2m', target: 0 }, + ], + }, +}; diff --git a/tests/k6/lib/helpers.ts b/tests/k6/lib/helpers.ts new file mode 100644 index 00000000..b12fe1a7 --- /dev/null +++ b/tests/k6/lib/helpers.ts @@ -0,0 +1,44 @@ +import { check } from 'k6'; +import type { RefinedResponse, ResponseType } from 'k6/http'; +import { Counter, Rate, Trend } from 'k6/metrics'; + +export const apiDuration = new Trend('api_duration', true); +export const apiErrors = new Rate('api_errors'); +export const apiRequests = new Counter('api_requests'); + +export function checkResponse( + res: RefinedResponse, + name: string, + expectedStatus = 200, +): boolean { + apiRequests.add(1); + apiDuration.add(res.timings.duration); + + const passed = check(res, { + [`${name}: status ${expectedStatus}`]: (r) => r.status === expectedStatus, + [`${name}: response time < 1s`]: (r) => r.timings.duration < 1000, + }); + + if (!passed) { + apiErrors.add(1); + } + + return passed; +} + +export function randomString(length = 8): string { + const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +} + +export function randomInt(min: number, max: number): number { + return Math.floor(Math.random() * (max - min)) + min; +} + +export function jitterSleep(baseSec: number, jitterSec = 1): number { + return baseSec + Math.random() * jitterSec; +} diff --git a/tests/k6/lib/k6-summary.d.ts b/tests/k6/lib/k6-summary.d.ts new file mode 100644 index 00000000..9e1b03a7 --- /dev/null +++ b/tests/k6/lib/k6-summary.d.ts @@ -0,0 +1,7 @@ +declare module 'https://jslib.k6.io/k6-summary/0.1.0/index.js' { + // biome-ignore lint/suspicious/noExplicitAny: k6 summary data is untyped + export function textSummary( + data: any, + options?: { indent?: string; enableColors?: boolean }, + ): string; +} diff --git a/tests/k6/package.json b/tests/k6/package.json new file mode 100644 index 00000000..3c3b5e27 --- /dev/null +++ b/tests/k6/package.json @@ -0,0 +1,8 @@ +{ + "private": true, + "name": "@simplemodule/k6", + "version": "0.0.0", + "devDependencies": { + "@types/k6": "^1.0.0" + } +} diff --git a/tests/k6/scenarios/audit-logs.ts b/tests/k6/scenarios/audit-logs.ts new file mode 100644 index 00000000..3006ba3f --- /dev/null +++ b/tests/k6/scenarios/audit-logs.ts @@ -0,0 +1,77 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { type AuthResult, authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:query-logs}': ['p(95)<500'], + 'http_req_duration{name:query-logs-filtered}': ['p(95)<500'], + 'http_req_duration{name:get-log-entry}': ['p(95)<500'], + 'http_req_duration{name:audit-stats}': ['p(95)<500'], + 'http_req_duration{name:export-csv}': ['p(95)<2000'], + 'http_req_duration{name:export-json}': ['p(95)<2000'], + }, +}; + +export function setup(): AuthResult { + return authenticate(); +} + +export default function (auth: AuthResult) { + const headers = authHeaders(auth.accessToken); + const baseUrl = `${config.baseUrl}/api/audit-logs`; + + const queryRes = http.get(baseUrl, { headers, tags: { name: 'query-logs' } }); + checkResponse(queryRes, 'query-logs'); + + const filteredRes = http.get(`${baseUrl}?module=Products&pageSize=10&sortDescending=true`, { + headers, + tags: { name: 'query-logs-filtered' }, + }); + checkResponse(filteredRes, 'query-logs-filtered'); + + if (queryRes.status === 200) { + try { + const result = JSON.parse(queryRes.body as string); + if (result.items?.length > 0) { + const entryId = result.items[0].id; + const getRes = http.get(`${baseUrl}/${entryId}`, { + headers, + tags: { name: 'get-log-entry' }, + }); + checkResponse(getRes, 'get-log-entry'); + } + } catch { + // empty audit log is fine + } + } + + const now = new Date().toISOString(); + const yesterday = new Date(Date.now() - 86400000).toISOString(); + const statsRes = http.get(`${baseUrl}/stats?from=${yesterday}&to=${now}`, { + headers, + tags: { name: 'audit-stats' }, + }); + checkResponse(statsRes, 'audit-stats'); + + const csvRes = http.get(`${baseUrl}/export?format=csv&pageSize=10`, { + headers, + tags: { name: 'export-csv' }, + }); + checkResponse(csvRes, 'export-csv'); + + const jsonRes = http.get(`${baseUrl}/export?format=json&pageSize=10`, { + headers, + tags: { name: 'export-json' }, + }); + checkResponse(jsonRes, 'export-json'); + + sleep(1); +} diff --git a/tests/k6/scenarios/auth.ts b/tests/k6/scenarios/auth.ts new file mode 100644 index 00000000..a1fb55cd --- /dev/null +++ b/tests/k6/scenarios/auth.ts @@ -0,0 +1,37 @@ +import { check, sleep } from 'k6'; +import http from 'k6/http'; +import { authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:auth}': ['p(95)<1000'], + 'http_req_duration{name:current-user}': ['p(95)<500'], + }, +}; + +export default function () { + const auth = authenticate(); + + const userRes = http.get(`${config.baseUrl}/api/users/me`, { + headers: authHeaders(auth.accessToken), + tags: { name: 'current-user' }, + }); + check(userRes, { + 'current-user: status 200': (r) => r.status === 200, + 'current-user: has email': (r) => { + try { + return JSON.parse(r.body as string).email !== undefined; + } catch { + return false; + } + }, + }); + + sleep(1); +} diff --git a/tests/k6/scenarios/background-jobs.ts b/tests/k6/scenarios/background-jobs.ts new file mode 100644 index 00000000..cced4657 --- /dev/null +++ b/tests/k6/scenarios/background-jobs.ts @@ -0,0 +1,62 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { type AuthResult, authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:list-jobs}': ['p(95)<500'], + 'http_req_duration{name:list-jobs-filtered}': ['p(95)<500'], + 'http_req_duration{name:get-job-by-id}': ['p(95)<500'], + 'http_req_duration{name:list-recurring}': ['p(95)<500'], + }, +}; + +export function setup(): AuthResult { + return authenticate(); +} + +export default function (auth: AuthResult) { + const headers = authHeaders(auth.accessToken); + const baseUrl = `${config.baseUrl}/api/jobs`; + + const listRes = http.get(baseUrl, { headers, tags: { name: 'list-jobs' } }); + checkResponse(listRes, 'list-jobs'); + + const filteredRes = http.get(`${baseUrl}?page=1&pageSize=10`, { + headers, + tags: { name: 'list-jobs-filtered' }, + }); + checkResponse(filteredRes, 'list-jobs-filtered'); + + if (listRes.status === 200) { + try { + const jobs = JSON.parse(listRes.body as string); + const items = jobs.items || jobs.data || jobs; + if (Array.isArray(items) && items.length > 0) { + const jobId = items[0].id; + const getRes = http.get(`${baseUrl}/${jobId}`, { + headers, + tags: { name: 'get-job-by-id' }, + }); + checkResponse(getRes, 'get-job-by-id', getRes.status === 404 ? 404 : 200); + } + } catch { + // ignore parse errors + } + } + + const recurringRes = http.get(`${baseUrl}/recurring`, { + headers, + tags: { name: 'list-recurring' }, + }); + checkResponse(recurringRes, 'list-recurring'); + + sleep(1); +} diff --git a/tests/k6/scenarios/feature-flags.ts b/tests/k6/scenarios/feature-flags.ts new file mode 100644 index 00000000..3dbf10eb --- /dev/null +++ b/tests/k6/scenarios/feature-flags.ts @@ -0,0 +1,101 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { type AuthResult, authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:list-flags}': ['p(95)<500'], + 'http_req_duration{name:check-flag}': ['p(95)<300'], + 'http_req_duration{name:update-flag}': ['p(95)<500'], + 'http_req_duration{name:get-overrides}': ['p(95)<500'], + 'http_req_duration{name:set-override}': ['p(95)<500'], + 'http_req_duration{name:delete-override}': ['p(95)<500'], + }, +}; + +export function setup(): AuthResult { + return authenticate(); +} + +export default function (auth: AuthResult) { + const headers = authHeaders(auth.accessToken); + const baseUrl = `${config.baseUrl}/api/feature-flags`; + + const listRes = http.get(baseUrl, { headers, tags: { name: 'list-flags' } }); + checkResponse(listRes, 'list-flags'); + + let flagName: string | null = null; + let originalEnabled = true; + if (listRes.status === 200) { + try { + const flags = JSON.parse(listRes.body as string); + const items = Array.isArray(flags) ? flags : flags.items || flags.data || []; + if (items.length > 0) { + flagName = items[0].name; + originalEnabled = items[0].isEnabled; + } + } catch { + // ignore + } + } + + if (flagName) { + const checkRes = http.get(`${baseUrl}/check/${flagName}`, { + headers, + tags: { name: 'check-flag' }, + }); + checkResponse(checkRes, 'check-flag'); + + const updateRes = http.put( + `${baseUrl}/${flagName}`, + JSON.stringify({ isEnabled: !originalEnabled }), + { headers, tags: { name: 'update-flag' } }, + ); + checkResponse(updateRes, 'update-flag'); + + http.put(`${baseUrl}/${flagName}`, JSON.stringify({ isEnabled: originalEnabled }), { + headers, + tags: { name: 'update-flag' }, + }); + + const overridesRes = http.get(`${baseUrl}/${flagName}/overrides`, { + headers, + tags: { name: 'get-overrides' }, + }); + checkResponse(overridesRes, 'get-overrides'); + + const overrideRes = http.post( + `${baseUrl}/${flagName}/overrides`, + JSON.stringify({ + overrideType: 0, + overrideValue: `k6-test-${randomString(6)}`, + isEnabled: true, + }), + { headers, tags: { name: 'set-override' } }, + ); + checkResponse(overrideRes, 'set-override', 201); + + if (overrideRes.status === 201) { + try { + const override = JSON.parse(overrideRes.body as string); + const overrideId = override.id; + const delRes = http.del(`${baseUrl}/overrides/${overrideId}`, null, { + headers, + tags: { name: 'delete-override' }, + }); + checkResponse(delRes, 'delete-override', 204); + } catch { + // ignore + } + } + } + + sleep(1); +} diff --git a/tests/k6/scenarios/file-storage.ts b/tests/k6/scenarios/file-storage.ts new file mode 100644 index 00000000..05af734d --- /dev/null +++ b/tests/k6/scenarios/file-storage.ts @@ -0,0 +1,69 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { type AuthResult, authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:list-files}': ['p(95)<500'], + 'http_req_duration{name:list-folders}': ['p(95)<500'], + 'http_req_duration{name:upload-file}': ['p(95)<2000'], + 'http_req_duration{name:get-file}': ['p(95)<500'], + 'http_req_duration{name:download-file}': ['p(95)<1000'], + 'http_req_duration{name:delete-file}': ['p(95)<500'], + }, +}; + +export function setup(): AuthResult { + return authenticate(); +} + +export default function (auth: AuthResult) { + const headers = authHeaders(auth.accessToken); + const baseUrl = `${config.baseUrl}/api/files`; + + const listRes = http.get(baseUrl, { headers, tags: { name: 'list-files' } }); + checkResponse(listRes, 'list-files'); + + const foldersRes = http.get(`${baseUrl}/folders`, { headers, tags: { name: 'list-folders' } }); + checkResponse(foldersRes, 'list-folders'); + + const fileContent = `k6 load test file content - ${randomString(32)}`; + const fileName = `k6-test-${randomString(8)}.txt`; + const uploadRes = http.post( + baseUrl, + { file: http.file(fileContent, fileName, 'text/plain') }, + { + headers: { Authorization: `Bearer ${auth.accessToken}` }, + tags: { name: 'upload-file' }, + }, + ); + checkResponse(uploadRes, 'upload-file', 201); + + if (uploadRes.status === 201) { + const fileId = JSON.parse(uploadRes.body as string).id; + + const getRes = http.get(`${baseUrl}/${fileId}`, { headers, tags: { name: 'get-file' } }); + checkResponse(getRes, 'get-file'); + + const downloadRes = http.get(`${baseUrl}/${fileId}/download`, { + headers, + tags: { name: 'download-file' }, + }); + checkResponse(downloadRes, 'download-file'); + + const deleteRes = http.del(`${baseUrl}/${fileId}`, null, { + headers, + tags: { name: 'delete-file' }, + }); + checkResponse(deleteRes, 'delete-file', 204); + } + + sleep(1); +} diff --git a/tests/k6/scenarios/health.ts b/tests/k6/scenarios/health.ts new file mode 100644 index 00000000..59c3c7a2 --- /dev/null +++ b/tests/k6/scenarios/health.ts @@ -0,0 +1,34 @@ +import { check, sleep } from 'k6'; +import http from 'k6/http'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:health}': ['p(95)<200'], + 'http_req_duration{name:alive}': ['p(95)<200'], + }, +}; + +export default function () { + const healthRes = http.get(`${config.baseUrl}/health`, { + tags: { name: 'health' }, + }); + check(healthRes, { + 'health: status 200': (r) => r.status === 200, + 'health: is healthy': (r) => typeof r.body === 'string' && r.body.includes('Healthy'), + }); + + const aliveRes = http.get(`${config.baseUrl}/alive`, { + tags: { name: 'alive' }, + }); + check(aliveRes, { + 'alive: status 200': (r) => r.status === 200, + }); + + sleep(1); +} diff --git a/tests/k6/scenarios/hotspots.ts b/tests/k6/scenarios/hotspots.ts new file mode 100644 index 00000000..507ce8bb --- /dev/null +++ b/tests/k6/scenarios/hotspots.ts @@ -0,0 +1,365 @@ +import { textSummary } from 'https://jslib.k6.io/k6-summary/0.1.0/index.js'; +import { sleep } from 'k6'; +import http from 'k6/http'; +import { Trend } from 'k6/metrics'; +import { authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { randomInt, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'load'; + +const endpoints: Record = { + health: new Trend('endpoint_health', true), + alive: new Trend('endpoint_alive', true), + token: new Trend('endpoint_token', true), + currentUser: new Trend('endpoint_current_user', true), + listProducts: new Trend('endpoint_list_products', true), + createProduct: new Trend('endpoint_create_product', true), + getProduct: new Trend('endpoint_get_product', true), + updateProduct: new Trend('endpoint_update_product', true), + deleteProduct: new Trend('endpoint_delete_product', true), + listOrders: new Trend('endpoint_list_orders', true), + createOrder: new Trend('endpoint_create_order', true), + getOrder: new Trend('endpoint_get_order', true), + deleteOrder: new Trend('endpoint_delete_order', true), + listPages: new Trend('endpoint_list_pages', true), + createPage: new Trend('endpoint_create_page', true), + getPage: new Trend('endpoint_get_page', true), + updatePageContent: new Trend('endpoint_update_page_content', true), + publishPage: new Trend('endpoint_publish_page', true), + unpublishPage: new Trend('endpoint_unpublish_page', true), + deletePage: new Trend('endpoint_delete_page', true), + listTags: new Trend('endpoint_list_tags', true), + listTemplates: new Trend('endpoint_list_templates', true), + listSettings: new Trend('endpoint_list_settings', true), + getDefinitions: new Trend('endpoint_get_definitions', true), + listMenus: new Trend('endpoint_list_menus', true), + getUserSettings: new Trend('endpoint_get_user_settings', true), + listUsers: new Trend('endpoint_list_users', true), + queryAuditLogs: new Trend('endpoint_query_audit_logs', true), + auditStats: new Trend('endpoint_audit_stats', true), + listFiles: new Trend('endpoint_list_files', true), + listFolders: new Trend('endpoint_list_folders', true), + searchMarketplace: new Trend('endpoint_search_marketplace', true), + listJobs: new Trend('endpoint_list_jobs', true), + listRecurring: new Trend('endpoint_list_recurring', true), + listFlags: new Trend('endpoint_list_flags', true), + checkFlag: new Trend('endpoint_check_flag', true), + listTenants: new Trend('endpoint_list_tenants', true), + createTenant: new Trend('endpoint_create_tenant', true), + getTenant: new Trend('endpoint_get_tenant', true), + getTenantFeatures: new Trend('endpoint_get_tenant_features', true), + deleteTenant: new Trend('endpoint_delete_tenant', true), +}; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.load.stages, + thresholds: { + ...defaultThresholds, + endpoint_health: ['p(95)<200'], + endpoint_alive: ['p(95)<200'], + endpoint_token: ['p(95)<1000'], + endpoint_current_user: ['p(95)<500'], + endpoint_list_products: ['p(95)<500'], + endpoint_create_product: ['p(95)<500'], + endpoint_get_product: ['p(95)<500'], + endpoint_update_product: ['p(95)<500'], + endpoint_delete_product: ['p(95)<500'], + endpoint_list_orders: ['p(95)<500'], + endpoint_create_order: ['p(95)<500'], + endpoint_get_order: ['p(95)<500'], + endpoint_delete_order: ['p(95)<500'], + endpoint_list_pages: ['p(95)<500'], + endpoint_create_page: ['p(95)<500'], + endpoint_get_page: ['p(95)<500'], + endpoint_update_page_content: ['p(95)<500'], + endpoint_publish_page: ['p(95)<500'], + endpoint_unpublish_page: ['p(95)<500'], + endpoint_delete_page: ['p(95)<500'], + endpoint_list_tags: ['p(95)<500'], + endpoint_list_templates: ['p(95)<500'], + endpoint_list_settings: ['p(95)<500'], + endpoint_get_definitions: ['p(95)<500'], + endpoint_list_menus: ['p(95)<500'], + endpoint_get_user_settings: ['p(95)<500'], + endpoint_list_users: ['p(95)<500'], + endpoint_query_audit_logs: ['p(95)<500'], + endpoint_audit_stats: ['p(95)<500'], + endpoint_list_files: ['p(95)<500'], + endpoint_list_folders: ['p(95)<500'], + endpoint_search_marketplace: ['p(95)<2000'], + endpoint_list_jobs: ['p(95)<500'], + endpoint_list_recurring: ['p(95)<500'], + endpoint_list_flags: ['p(95)<500'], + endpoint_check_flag: ['p(95)<300'], + endpoint_list_tenants: ['p(95)<500'], + endpoint_create_tenant: ['p(95)<500'], + endpoint_get_tenant: ['p(95)<500'], + endpoint_get_tenant_features: ['p(95)<500'], + endpoint_delete_tenant: ['p(95)<500'], + }, +}; + +interface MetricEntry { + name: string; + avg: number; + med: number; + p90: number; + p95: number; + p99: number; + max: number; + count: number; +} + +function fmt(ms: number): string { + if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`; + return `${ms.toFixed(1)}ms`; +} + +// biome-ignore lint/suspicious/noExplicitAny: k6 handleSummary data has no typed definition +export function handleSummary(data: any) { + const endpointMetrics: MetricEntry[] = []; + for (const [key, metric] of Object.entries(data.metrics)) { + if (key.startsWith('endpoint_')) { + const m = metric as { values: Record }; + if (m.values.count === 0) continue; + endpointMetrics.push({ + name: key.replace('endpoint_', '').replace(/_/g, ' '), + avg: m.values.avg, + med: m.values.med, + p90: m.values['p(90)'], + p95: m.values['p(95)'], + p99: m.values['p(99)'], + max: m.values.max, + count: m.values.count, + }); + } + } + + endpointMetrics.sort((a, b) => b.p95 - a.p95); + + let report = '\n====== HOTSPOT REPORT ======\n\n'; + report += 'Endpoints sorted by p95 latency (slowest first):\n\n'; + report += `${'Endpoint'.padEnd(30)} ${'Avg'.padStart(8)} ${'Med'.padStart(8)} ${'p90'.padStart(8)} ${'p95'.padStart(8)} ${'p99'.padStart(8)} ${'Max'.padStart(8)} ${'Count'.padStart(7)}\n`; + report += `${'-'.repeat(105)}\n`; + + for (const m of endpointMetrics) { + const flag = m.p95 > 500 ? ' *** HOTSPOT' : m.p95 > 200 ? ' * SLOW' : ''; + report += `${m.name.padEnd(30)} ${fmt(m.avg).padStart(8)} ${fmt(m.med).padStart(8)} ${fmt(m.p90).padStart(8)} ${fmt(m.p95).padStart(8)} ${fmt(m.p99).padStart(8)} ${fmt(m.max).padStart(8)} ${String(m.count).padStart(7)}${flag}\n`; + } + + report += '\nLegend: *** HOTSPOT = p95 > 500ms | * SLOW = p95 > 200ms\n'; + report += '============================\n'; + + return { + stdout: textSummary(data, { indent: ' ', enableColors: true }) + report, + 'tests/k6/results/hotspot-report.txt': report, + }; +} + +interface SetupData { + accessToken: string; + userId: string; + productId: number; +} + +export function setup(): SetupData { + const auth = authenticate(); + const headers = authHeaders(auth.accessToken); + + const userRes = http.get(`${config.baseUrl}/api/users/me`, { headers }); + const userId = JSON.parse(userRes.body as string).id; + + const productRes = http.post( + `${config.baseUrl}/api/products`, + JSON.stringify({ name: `k6-hotspot-product-${Date.now()}`, price: 9.99 }), + { headers }, + ); + const productId = JSON.parse(productRes.body as string).id; + + return { accessToken: auth.accessToken, userId, productId }; +} + +export default function (data: SetupData) { + const headers = authHeaders(data.accessToken); + let res: ReturnType; + + res = http.get(`${config.baseUrl}/health`); + endpoints.health.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/alive`); + endpoints.alive.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/users/me`, { headers }); + endpoints.currentUser.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/products`, { headers }); + endpoints.listProducts.add(res.timings.duration); + + const product = { name: `k6-hs-${randomString()}`, price: randomInt(1, 100) }; + res = http.post(`${config.baseUrl}/api/products`, JSON.stringify(product), { headers }); + endpoints.createProduct.add(res.timings.duration); + + if (res.status === 201) { + const pid = JSON.parse(res.body as string).id; + + res = http.get(`${config.baseUrl}/api/products/${pid}`, { headers }); + endpoints.getProduct.add(res.timings.duration); + + res = http.put( + `${config.baseUrl}/api/products/${pid}`, + JSON.stringify({ ...product, name: `k6-upd-${randomString()}` }), + { headers }, + ); + endpoints.updateProduct.add(res.timings.duration); + + res = http.del(`${config.baseUrl}/api/products/${pid}`, null, { headers }); + endpoints.deleteProduct.add(res.timings.duration); + } + + res = http.get(`${config.baseUrl}/api/orders`, { headers }); + endpoints.listOrders.add(res.timings.duration); + + res = http.post( + `${config.baseUrl}/api/orders`, + JSON.stringify({ + userId: data.userId, + items: [{ productId: data.productId, quantity: randomInt(1, 5) }], + }), + { headers }, + ); + endpoints.createOrder.add(res.timings.duration); + + if (res.status === 201) { + const oid = JSON.parse(res.body as string).id; + + res = http.get(`${config.baseUrl}/api/orders/${oid}`, { headers }); + endpoints.getOrder.add(res.timings.duration); + + res = http.del(`${config.baseUrl}/api/orders/${oid}`, null, { headers }); + endpoints.deleteOrder.add(res.timings.duration); + } + + res = http.get(`${config.baseUrl}/api/pagebuilder`, { headers }); + endpoints.listPages.add(res.timings.duration); + + const slug = `k6-hs-${randomString(10)}`; + res = http.post( + `${config.baseUrl}/api/pagebuilder`, + JSON.stringify({ title: `K6 ${randomString()}`, slug }), + { headers }, + ); + endpoints.createPage.add(res.timings.duration); + + if (res.status === 201) { + const pgid = JSON.parse(res.body as string).id; + + res = http.get(`${config.baseUrl}/api/pagebuilder/${pgid}`, { headers }); + endpoints.getPage.add(res.timings.duration); + + res = http.put( + `${config.baseUrl}/api/pagebuilder/${pgid}/content`, + JSON.stringify({ content: `

${randomString(50)}

` }), + { headers }, + ); + endpoints.updatePageContent.add(res.timings.duration); + + res = http.post(`${config.baseUrl}/api/pagebuilder/${pgid}/publish`, null, { headers }); + endpoints.publishPage.add(res.timings.duration); + + res = http.post(`${config.baseUrl}/api/pagebuilder/${pgid}/unpublish`, null, { headers }); + endpoints.unpublishPage.add(res.timings.duration); + + res = http.del(`${config.baseUrl}/api/pagebuilder/${pgid}`, null, { headers }); + endpoints.deletePage.add(res.timings.duration); + } + + res = http.get(`${config.baseUrl}/api/pagebuilder/tags`, { headers }); + endpoints.listTags.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/pagebuilder/templates`, { headers }); + endpoints.listTemplates.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/settings`, { headers }); + endpoints.listSettings.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/settings/definitions`, { headers }); + endpoints.getDefinitions.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/settings/menus`, { headers }); + endpoints.listMenus.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/settings/me`, { headers }); + endpoints.getUserSettings.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/users`, { headers }); + endpoints.listUsers.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/audit-logs`, { headers }); + endpoints.queryAuditLogs.add(res.timings.duration); + + const now = new Date().toISOString(); + const yesterday = new Date(Date.now() - 86400000).toISOString(); + res = http.get(`${config.baseUrl}/api/audit-logs/stats?from=${yesterday}&to=${now}`, { + headers, + }); + endpoints.auditStats.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/files`, { headers }); + endpoints.listFiles.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/files/folders`, { headers }); + endpoints.listFolders.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/marketplace?take=5`); + endpoints.searchMarketplace.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/jobs`, { headers }); + endpoints.listJobs.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/jobs/recurring`, { headers }); + endpoints.listRecurring.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/feature-flags`, { headers }); + endpoints.listFlags.add(res.timings.duration); + + if (res.status === 200) { + try { + const flags = JSON.parse(res.body as string); + const items = Array.isArray(flags) ? flags : flags.items || []; + if (items.length > 0) { + res = http.get(`${config.baseUrl}/api/feature-flags/check/${items[0].name}`, { headers }); + endpoints.checkFlag.add(res.timings.duration); + } + } catch { + // ignore + } + } + + res = http.get(`${config.baseUrl}/api/tenants`, { headers }); + endpoints.listTenants.add(res.timings.duration); + + const tenantSuffix = randomString(8); + res = http.post( + `${config.baseUrl}/api/tenants`, + JSON.stringify({ name: `k6-hs-${tenantSuffix}`, slug: `k6hs${tenantSuffix}` }), + { headers }, + ); + endpoints.createTenant.add(res.timings.duration); + + if (res.status === 201) { + const tid = JSON.parse(res.body as string).id; + + res = http.get(`${config.baseUrl}/api/tenants/${tid}`, { headers }); + endpoints.getTenant.add(res.timings.duration); + + res = http.get(`${config.baseUrl}/api/tenants/${tid}/features`, { headers }); + endpoints.getTenantFeatures.add(res.timings.duration); + + res = http.del(`${config.baseUrl}/api/tenants/${tid}`, null, { headers }); + endpoints.deleteTenant.add(res.timings.duration); + } + + sleep(0.5); +} diff --git a/tests/k6/scenarios/marketplace.ts b/tests/k6/scenarios/marketplace.ts new file mode 100644 index 00000000..cbb26306 --- /dev/null +++ b/tests/k6/scenarios/marketplace.ts @@ -0,0 +1,57 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, randomInt } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:search-packages}': ['p(95)<2000'], + 'http_req_duration{name:search-with-query}': ['p(95)<2000'], + 'http_req_duration{name:search-with-category}': ['p(95)<2000'], + 'http_req_duration{name:get-package}': ['p(95)<2000'], + }, +}; + +const searchQueries = ['simplemodule', 'aspnet', 'blazor', 'entityframework', 'authentication']; +const categories = ['All', 'Auth', 'Storage', 'UI', 'Analytics', 'Integration']; + +export default function () { + const baseUrl = `${config.baseUrl}/api/marketplace`; + + const searchRes = http.get(`${baseUrl}?take=5`, { tags: { name: 'search-packages' } }); + checkResponse(searchRes, 'search-packages'); + + const query = searchQueries[randomInt(0, searchQueries.length)]; + const queryRes = http.get(`${baseUrl}?q=${query}&take=5`, { + tags: { name: 'search-with-query' }, + }); + checkResponse(queryRes, 'search-with-query'); + + const category = categories[randomInt(0, categories.length)]; + const categoryRes = http.get(`${baseUrl}?category=${category}&take=5`, { + tags: { name: 'search-with-category' }, + }); + checkResponse(categoryRes, 'search-with-category'); + + if (searchRes.status === 200) { + try { + const results = JSON.parse(searchRes.body as string); + if (results.packages?.length > 0) { + const packageId = results.packages[0].id; + const detailRes = http.get(`${baseUrl}/${packageId}`, { + tags: { name: 'get-package' }, + }); + checkResponse(detailRes, 'get-package'); + } + } catch { + // search may return empty or different format + } + } + + sleep(1); +} diff --git a/tests/k6/scenarios/mixed.ts b/tests/k6/scenarios/mixed.ts new file mode 100644 index 00000000..261d42a1 --- /dev/null +++ b/tests/k6/scenarios/mixed.ts @@ -0,0 +1,170 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, jitterSleep, randomInt, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'load'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.load.stages, + thresholds: { + ...defaultThresholds, + api_duration: ['p(95)<800', 'p(99)<2000'], + api_errors: ['rate<0.05'], + }, +}; + +interface SetupData { + accessToken: string; + userId: string; + productId: number; +} + +export function setup(): SetupData { + const auth = authenticate(); + const headers = authHeaders(auth.accessToken); + + const userRes = http.get(`${config.baseUrl}/api/users/me`, { headers }); + const userId = JSON.parse(userRes.body as string).id; + + const productRes = http.post( + `${config.baseUrl}/api/products`, + JSON.stringify({ name: `k6-mixed-product-${Date.now()}`, price: 9.99 }), + { headers }, + ); + const productId = JSON.parse(productRes.body as string).id; + + return { accessToken: auth.accessToken, userId, productId }; +} + +interface WeightedAction { + weight: number; + fn: () => void; +} + +function weightedRandom(items: WeightedAction[]): () => void { + const total = items.reduce((sum, item) => sum + item.weight, 0); + let random = Math.random() * total; + for (const item of items) { + random -= item.weight; + if (random <= 0) return item.fn; + } + return items[0].fn; +} + +export default function (auth: SetupData) { + const headers = authHeaders(auth.accessToken); + + const action = weightedRandom([ + { weight: 25, fn: () => browseProducts(headers) }, + { weight: 15, fn: () => browseOrders(headers) }, + { weight: 15, fn: () => browsePages(headers) }, + { weight: 10, fn: () => browseMarketplace() }, + { weight: 10, fn: () => crudProduct(headers) }, + { weight: 10, fn: () => crudOrder(headers, auth.userId, auth.productId) }, + { weight: 5, fn: () => browseAuditLogs(headers) }, + { weight: 5, fn: () => browseFiles(headers) }, + { weight: 5, fn: () => getCurrentUser(headers) }, + ]); + + action(); + sleep(jitterSleep(0.5, 1.5)); +} + +function browseProducts(headers: Record) { + const res = http.get(`${config.baseUrl}/api/products`, { + headers, + tags: { name: 'browse-products' }, + }); + checkResponse(res, 'browse-products'); +} + +function browseOrders(headers: Record) { + const res = http.get(`${config.baseUrl}/api/orders`, { + headers, + tags: { name: 'browse-orders' }, + }); + checkResponse(res, 'browse-orders'); +} + +function browsePages(headers: Record) { + const res = http.get(`${config.baseUrl}/api/pagebuilder`, { + headers, + tags: { name: 'browse-pages' }, + }); + checkResponse(res, 'browse-pages'); +} + +function browseMarketplace() { + const res = http.get(`${config.baseUrl}/api/marketplace?take=5`, { + tags: { name: 'browse-marketplace' }, + }); + checkResponse(res, 'browse-marketplace'); +} + +function browseAuditLogs(headers: Record) { + const res = http.get(`${config.baseUrl}/api/audit-logs`, { + headers, + tags: { name: 'browse-audit-logs' }, + }); + checkResponse(res, 'browse-audit-logs'); +} + +function browseFiles(headers: Record) { + const res = http.get(`${config.baseUrl}/api/files`, { + headers, + tags: { name: 'browse-files' }, + }); + checkResponse(res, 'browse-files'); +} + +function getCurrentUser(headers: Record) { + const res = http.get(`${config.baseUrl}/api/users/me`, { + headers, + tags: { name: 'get-current-user' }, + }); + checkResponse(res, 'get-current-user'); +} + +function crudProduct(headers: Record) { + const baseUrl = `${config.baseUrl}/api/products`; + + const createRes = http.post( + baseUrl, + JSON.stringify({ + name: `k6-mixed-${randomString()}`, + description: 'Mixed load test product', + price: randomInt(100, 10000) / 100, + }), + { headers, tags: { name: 'crud-create-product' } }, + ); + checkResponse(createRes, 'crud-create-product', 201); + + if (createRes.status === 201) { + const id = JSON.parse(createRes.body as string).id; + http.get(`${baseUrl}/${id}`, { headers, tags: { name: 'crud-get-product' } }); + http.del(`${baseUrl}/${id}`, null, { headers, tags: { name: 'crud-delete-product' } }); + } +} + +function crudOrder(headers: Record, userId: string, productId: number) { + const baseUrl = `${config.baseUrl}/api/orders`; + + const createRes = http.post( + baseUrl, + JSON.stringify({ + userId, + items: [{ productId, quantity: randomInt(1, 5) }], + }), + { headers, tags: { name: 'crud-create-order' } }, + ); + checkResponse(createRes, 'crud-create-order', 201); + + if (createRes.status === 201) { + const id = JSON.parse(createRes.body as string).id; + http.get(`${baseUrl}/${id}`, { headers, tags: { name: 'crud-get-order' } }); + http.del(`${baseUrl}/${id}`, null, { headers, tags: { name: 'crud-delete-order' } }); + } +} diff --git a/tests/k6/scenarios/orders.ts b/tests/k6/scenarios/orders.ts new file mode 100644 index 00000000..5fdeedf1 --- /dev/null +++ b/tests/k6/scenarios/orders.ts @@ -0,0 +1,82 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, randomInt, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:list-orders}': ['p(95)<500'], + 'http_req_duration{name:create-order}': ['p(95)<800'], + 'http_req_duration{name:get-order}': ['p(95)<500'], + 'http_req_duration{name:delete-order}': ['p(95)<500'], + }, +}; + +interface SetupData { + accessToken: string; + userId: string; + productId: number; +} + +export function setup(): SetupData { + const auth = authenticate(); + const headers = authHeaders(auth.accessToken); + + const userRes = http.get(`${config.baseUrl}/api/users/me`, { headers }); + const userId = JSON.parse(userRes.body as string).id; + + const productRes = http.post( + `${config.baseUrl}/api/products`, + JSON.stringify({ name: `k6-order-product-${randomString()}`, price: 9.99 }), + { headers }, + ); + const productId = JSON.parse(productRes.body as string).id; + + return { accessToken: auth.accessToken, userId, productId }; +} + +export default function (data: SetupData) { + const headers = authHeaders(data.accessToken); + const baseUrl = `${config.baseUrl}/api/orders`; + + const listRes = http.get(baseUrl, { + headers, + tags: { name: 'list-orders' }, + }); + checkResponse(listRes, 'list-orders'); + + const order = { + userId: data.userId, + items: [{ productId: data.productId, quantity: randomInt(1, 10) }], + }; + const createRes = http.post(baseUrl, JSON.stringify(order), { + headers, + tags: { name: 'create-order' }, + }); + checkResponse(createRes, 'create-order', 201); + + if (createRes.status === 201) { + const created = JSON.parse(createRes.body as string); + const orderId = created.id; + + const getRes = http.get(`${baseUrl}/${orderId}`, { + headers, + tags: { name: 'get-order' }, + }); + checkResponse(getRes, 'get-order'); + + const deleteRes = http.del(`${baseUrl}/${orderId}`, null, { + headers, + tags: { name: 'delete-order' }, + }); + checkResponse(deleteRes, 'delete-order', 204); + } + + sleep(1); +} diff --git a/tests/k6/scenarios/page-lifecycle.ts b/tests/k6/scenarios/page-lifecycle.ts new file mode 100644 index 00000000..81a509c7 --- /dev/null +++ b/tests/k6/scenarios/page-lifecycle.ts @@ -0,0 +1,172 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { type AuthResult, authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:create-page}': ['p(95)<800'], + 'http_req_duration{name:update-page}': ['p(95)<500'], + 'http_req_duration{name:update-content}': ['p(95)<500'], + 'http_req_duration{name:publish-page}': ['p(95)<500'], + 'http_req_duration{name:unpublish-page}': ['p(95)<500'], + 'http_req_duration{name:delete-page}': ['p(95)<500'], + 'http_req_duration{name:list-trash}': ['p(95)<500'], + 'http_req_duration{name:restore-page}': ['p(95)<500'], + 'http_req_duration{name:permanent-delete}': ['p(95)<500'], + 'http_req_duration{name:list-tags}': ['p(95)<500'], + 'http_req_duration{name:add-tag}': ['p(95)<500'], + 'http_req_duration{name:remove-tag}': ['p(95)<500'], + 'http_req_duration{name:list-templates}': ['p(95)<500'], + 'http_req_duration{name:create-template}': ['p(95)<800'], + 'http_req_duration{name:delete-template}': ['p(95)<500'], + }, +}; + +export function setup(): AuthResult { + return authenticate(); +} + +export default function (auth: AuthResult) { + const headers = authHeaders(auth.accessToken); + const baseUrl = `${config.baseUrl}/api/pagebuilder`; + + const slug = `k6-lifecycle-${randomString(8)}`; + const createRes = http.post( + baseUrl, + JSON.stringify({ title: `K6 Lifecycle ${randomString(6)}`, slug }), + { headers, tags: { name: 'create-page' } }, + ); + checkResponse(createRes, 'create-page', 201); + + if (createRes.status === 201) { + const page = JSON.parse(createRes.body as string); + const pageId = page.id; + + const updateRes = http.put( + `${baseUrl}/${pageId}`, + JSON.stringify({ + title: `K6 Updated ${randomString(6)}`, + slug, + order: 0, + isPublished: false, + metaDescription: 'k6 load test page', + }), + { headers, tags: { name: 'update-page' } }, + ); + checkResponse(updateRes, 'update-page', 204); + + const contentRes = http.put( + `${baseUrl}/${pageId}/content`, + JSON.stringify({ content: `

Load Test

${randomString(100)}

` }), + { headers, tags: { name: 'update-content' } }, + ); + checkResponse(contentRes, 'update-content'); + + const publishRes = http.post(`${baseUrl}/${pageId}/publish`, null, { + headers, + tags: { name: 'publish-page' }, + }); + checkResponse(publishRes, 'publish-page'); + + const unpublishRes = http.post(`${baseUrl}/${pageId}/unpublish`, null, { + headers, + tags: { name: 'unpublish-page' }, + }); + checkResponse(unpublishRes, 'unpublish-page'); + + const deleteRes = http.del(`${baseUrl}/${pageId}`, null, { + headers, + tags: { name: 'delete-page' }, + }); + checkResponse(deleteRes, 'delete-page', 204); + + const trashRes = http.get(`${baseUrl}/trash`, { headers, tags: { name: 'list-trash' } }); + checkResponse(trashRes, 'list-trash'); + + const restoreRes = http.post(`${baseUrl}/${pageId}/restore`, null, { + headers, + tags: { name: 'restore-page' }, + }); + checkResponse(restoreRes, 'restore-page'); + + const permDeleteRes = http.del(`${baseUrl}/${pageId}/permanent`, null, { + headers, + tags: { name: 'permanent-delete' }, + }); + checkResponse(permDeleteRes, 'permanent-delete', 204); + } + + const tagsRes = http.get(`${baseUrl}/tags`, { headers, tags: { name: 'list-tags' } }); + checkResponse(tagsRes, 'list-tags'); + + const tagSlug = `k6-tag-${randomString(8)}`; + const tagPageRes = http.post( + baseUrl, + JSON.stringify({ title: `K6 Tag Page ${randomString(4)}`, slug: tagSlug }), + { headers }, + ); + + if (tagPageRes.status === 201) { + const tagPageId = JSON.parse(tagPageRes.body as string).id; + + const addTagRes = http.post( + `${baseUrl}/${tagPageId}/tags`, + JSON.stringify({ name: `k6-tag-${randomString(4)}` }), + { headers, tags: { name: 'add-tag' } }, + ); + checkResponse(addTagRes, 'add-tag', 204); + + const updatedTags = http.get(`${baseUrl}/tags`, { headers }); + if (updatedTags.status === 200) { + try { + const allTags = JSON.parse(updatedTags.body as string) as Array<{ + id: number; + name: string; + }>; + const ourTag = allTags.find((t) => t.name?.startsWith('k6-tag-')); + if (ourTag) { + const removeTagRes = http.del(`${baseUrl}/${tagPageId}/tags/${ourTag.id}`, null, { + headers, + tags: { name: 'remove-tag' }, + }); + checkResponse(removeTagRes, 'remove-tag', 204); + } + } catch { + // tag operations are best-effort in load tests + } + } + + http.del(`${baseUrl}/${tagPageId}/permanent`, null, { headers }); + } + + const templatesRes = http.get(`${baseUrl}/templates`, { + headers, + tags: { name: 'list-templates' }, + }); + checkResponse(templatesRes, 'list-templates'); + + const createTemplateRes = http.post( + `${baseUrl}/templates`, + JSON.stringify({ name: `k6-template-${randomString(8)}`, content: '{"blocks":[]}' }), + { headers, tags: { name: 'create-template' } }, + ); + checkResponse(createTemplateRes, 'create-template', 201); + + if (createTemplateRes.status === 201) { + const templateId = JSON.parse(createTemplateRes.body as string).id; + const delTemplateRes = http.del(`${baseUrl}/templates/${templateId}`, null, { + headers, + tags: { name: 'delete-template' }, + }); + checkResponse(delTemplateRes, 'delete-template', 204); + } + + sleep(1); +} diff --git a/tests/k6/scenarios/pages.ts b/tests/k6/scenarios/pages.ts new file mode 100644 index 00000000..99516907 --- /dev/null +++ b/tests/k6/scenarios/pages.ts @@ -0,0 +1,65 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { type AuthResult, authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:list-pages}': ['p(95)<500'], + 'http_req_duration{name:create-page}': ['p(95)<800'], + 'http_req_duration{name:get-page}': ['p(95)<500'], + 'http_req_duration{name:update-page-content}': ['p(95)<800'], + 'http_req_duration{name:delete-page}': ['p(95)<500'], + }, +}; + +export function setup(): AuthResult { + return authenticate(); +} + +export default function (auth: AuthResult) { + const headers = authHeaders(auth.accessToken); + const baseUrl = `${config.baseUrl}/api/pagebuilder`; + + const listRes = http.get(baseUrl, { headers, tags: { name: 'list-pages' } }); + checkResponse(listRes, 'list-pages'); + + const slug = `k6-page-${randomString()}`; + const createRes = http.post( + baseUrl, + JSON.stringify({ title: `K6 Test Page ${randomString()}`, slug }), + { headers, tags: { name: 'create-page' } }, + ); + checkResponse(createRes, 'create-page', 201); + + if (createRes.status === 201) { + const created = JSON.parse(createRes.body as string); + const pageId = created.id; + + const getRes = http.get(`${baseUrl}/${pageId}`, { headers, tags: { name: 'get-page' } }); + checkResponse(getRes, 'get-page'); + + const updateContentRes = http.put( + `${baseUrl}/${pageId}/content`, + JSON.stringify({ + content: `

Load Test Content

Generated by k6 at ${new Date().toISOString()}

`, + }), + { headers, tags: { name: 'update-page-content' } }, + ); + checkResponse(updateContentRes, 'update-page-content'); + + const deleteRes = http.del(`${baseUrl}/${pageId}`, null, { + headers, + tags: { name: 'delete-page' }, + }); + checkResponse(deleteRes, 'delete-page', 204); + } + + sleep(1); +} diff --git a/tests/k6/scenarios/products.ts b/tests/k6/scenarios/products.ts new file mode 100644 index 00000000..ab9ca5bd --- /dev/null +++ b/tests/k6/scenarios/products.ts @@ -0,0 +1,76 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { type AuthResult, authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, randomInt, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:list-products}': ['p(95)<500'], + 'http_req_duration{name:create-product}': ['p(95)<800'], + 'http_req_duration{name:get-product}': ['p(95)<500'], + 'http_req_duration{name:update-product}': ['p(95)<800'], + 'http_req_duration{name:delete-product}': ['p(95)<500'], + }, +}; + +export function setup(): AuthResult { + return authenticate(); +} + +export default function (auth: AuthResult) { + const headers = authHeaders(auth.accessToken); + const baseUrl = `${config.baseUrl}/api/products`; + + const listRes = http.get(baseUrl, { + headers, + tags: { name: 'list-products' }, + }); + checkResponse(listRes, 'list-products'); + + const product = { + name: `k6-product-${randomString()}`, + description: 'Load test product created by k6', + price: randomInt(100, 10000) / 100, + }; + const createRes = http.post(baseUrl, JSON.stringify(product), { + headers, + tags: { name: 'create-product' }, + }); + checkResponse(createRes, 'create-product', 201); + + if (createRes.status === 201) { + const created = JSON.parse(createRes.body as string); + const productId = created.id; + + const getRes = http.get(`${baseUrl}/${productId}`, { + headers, + tags: { name: 'get-product' }, + }); + checkResponse(getRes, 'get-product'); + + const updateRes = http.put( + `${baseUrl}/${productId}`, + JSON.stringify({ + ...product, + name: `k6-updated-${randomString()}`, + price: randomInt(100, 10000) / 100, + }), + { headers, tags: { name: 'update-product' } }, + ); + checkResponse(updateRes, 'update-product'); + + const deleteRes = http.del(`${baseUrl}/${productId}`, null, { + headers, + tags: { name: 'delete-product' }, + }); + checkResponse(deleteRes, 'delete-product', 204); + } + + sleep(1); +} diff --git a/tests/k6/scenarios/settings.ts b/tests/k6/scenarios/settings.ts new file mode 100644 index 00000000..6f3a4a14 --- /dev/null +++ b/tests/k6/scenarios/settings.ts @@ -0,0 +1,105 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { type AuthResult, authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:list-settings}': ['p(95)<500'], + 'http_req_duration{name:get-definitions}': ['p(95)<500'], + 'http_req_duration{name:update-setting}': ['p(95)<500'], + 'http_req_duration{name:get-setting}': ['p(95)<500'], + 'http_req_duration{name:delete-setting}': ['p(95)<500'], + 'http_req_duration{name:get-user-settings}': ['p(95)<500'], + 'http_req_duration{name:list-menus}': ['p(95)<500'], + 'http_req_duration{name:create-menu}': ['p(95)<500'], + 'http_req_duration{name:delete-menu}': ['p(95)<500'], + 'http_req_duration{name:available-pages}': ['p(95)<500'], + }, +}; + +export function setup(): AuthResult { + return authenticate(); +} + +export default function (auth: AuthResult) { + const headers = authHeaders(auth.accessToken); + const settingsUrl = `${config.baseUrl}/api/settings`; + + const listRes = http.get(settingsUrl, { headers, tags: { name: 'list-settings' } }); + checkResponse(listRes, 'list-settings'); + + const defsRes = http.get(`${settingsUrl}/definitions`, { + headers, + tags: { name: 'get-definitions' }, + }); + checkResponse(defsRes, 'get-definitions'); + + const settingKey = `k6.test.${randomString(6)}`; + const updateRes = http.put( + settingsUrl, + JSON.stringify({ key: settingKey, value: 'k6-test-value', scope: 0 }), + { headers, tags: { name: 'update-setting' } }, + ); + checkResponse(updateRes, 'update-setting', 204); + + const getRes = http.get(`${settingsUrl}/${settingKey}?scope=0`, { + headers, + tags: { name: 'get-setting' }, + }); + checkResponse(getRes, 'get-setting'); + + const delRes = http.del(`${settingsUrl}/${settingKey}?scope=0`, null, { + headers, + tags: { name: 'delete-setting' }, + }); + checkResponse(delRes, 'delete-setting', 204); + + const userSettingsRes = http.get(`${settingsUrl}/me`, { + headers, + tags: { name: 'get-user-settings' }, + }); + checkResponse(userSettingsRes, 'get-user-settings', userSettingsRes.status === 401 ? 401 : 200); + + const menusUrl = `${settingsUrl}/menus`; + + const menusRes = http.get(menusUrl, { headers, tags: { name: 'list-menus' } }); + checkResponse(menusRes, 'list-menus'); + + const pagesRes = http.get(`${menusUrl}/available-pages`, { + headers, + tags: { name: 'available-pages' }, + }); + checkResponse(pagesRes, 'available-pages'); + + const createMenuRes = http.post( + menusUrl, + JSON.stringify({ + label: `k6-menu-${randomString(6)}`, + url: '/k6-test', + icon: '', + openInNewTab: false, + isVisible: true, + isHomePage: false, + }), + { headers, tags: { name: 'create-menu' } }, + ); + checkResponse(createMenuRes, 'create-menu', 201); + + if (createMenuRes.status === 201) { + const menuId = JSON.parse(createMenuRes.body as string).id; + const delMenuRes = http.del(`${menusUrl}/${menuId}`, null, { + headers, + tags: { name: 'delete-menu' }, + }); + checkResponse(delMenuRes, 'delete-menu', 204); + } + + sleep(1); +} diff --git a/tests/k6/scenarios/tenants.ts b/tests/k6/scenarios/tenants.ts new file mode 100644 index 00000000..2bb4781e --- /dev/null +++ b/tests/k6/scenarios/tenants.ts @@ -0,0 +1,140 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { type AuthResult, authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:list-tenants}': ['p(95)<500'], + 'http_req_duration{name:create-tenant}': ['p(95)<500'], + 'http_req_duration{name:get-tenant}': ['p(95)<500'], + 'http_req_duration{name:update-tenant}': ['p(95)<500'], + 'http_req_duration{name:change-status}': ['p(95)<500'], + 'http_req_duration{name:add-host}': ['p(95)<500'], + 'http_req_duration{name:remove-host}': ['p(95)<500'], + 'http_req_duration{name:get-tenant-features}': ['p(95)<500'], + 'http_req_duration{name:set-tenant-feature}': ['p(95)<500'], + 'http_req_duration{name:delete-tenant-feature}': ['p(95)<500'], + 'http_req_duration{name:delete-tenant}': ['p(95)<500'], + }, +}; + +export function setup(): AuthResult { + return authenticate(); +} + +export default function (auth: AuthResult) { + const headers = authHeaders(auth.accessToken); + const baseUrl = `${config.baseUrl}/api/tenants`; + const suffix = randomString(6); + + const listRes = http.get(baseUrl, { headers, tags: { name: 'list-tenants' } }); + checkResponse(listRes, 'list-tenants'); + + const createRes = http.post( + baseUrl, + JSON.stringify({ name: `k6-tenant-${suffix}`, slug: `k6-${suffix}` }), + { headers, tags: { name: 'create-tenant' } }, + ); + checkResponse(createRes, 'create-tenant', 201); + + if (createRes.status !== 201) { + sleep(1); + return; + } + + let tenantId: number; + try { + tenantId = JSON.parse(createRes.body as string).id; + } catch { + sleep(1); + return; + } + + const getRes = http.get(`${baseUrl}/${tenantId}`, { headers, tags: { name: 'get-tenant' } }); + checkResponse(getRes, 'get-tenant'); + + const updateRes = http.put( + `${baseUrl}/${tenantId}`, + JSON.stringify({ name: `k6-tenant-${suffix}-updated` }), + { headers, tags: { name: 'update-tenant' } }, + ); + checkResponse(updateRes, 'update-tenant'); + + const statusRes = http.put(`${baseUrl}/${tenantId}/status`, JSON.stringify({ status: 1 }), { + headers, + tags: { name: 'change-status' }, + }); + checkResponse(statusRes, 'change-status'); + + http.put(`${baseUrl}/${tenantId}/status`, JSON.stringify({ status: 0 }), { + headers, + tags: { name: 'change-status' }, + }); + + const hostName = `k6-${suffix}.example.com`; + const addHostRes = http.post(`${baseUrl}/${tenantId}/hosts`, JSON.stringify({ hostName }), { + headers, + tags: { name: 'add-host' }, + }); + checkResponse(addHostRes, 'add-host', 201); + + if (addHostRes.status === 201) { + try { + const host = JSON.parse(addHostRes.body as string); + const hostId = host.id; + const removeHostRes = http.del(`${baseUrl}/${tenantId}/hosts/${hostId}`, null, { + headers, + tags: { name: 'remove-host' }, + }); + checkResponse(removeHostRes, 'remove-host', 204); + } catch { + // ignore + } + } + + const featuresRes = http.get(`${baseUrl}/${tenantId}/features`, { + headers, + tags: { name: 'get-tenant-features' }, + }); + checkResponse(featuresRes, 'get-tenant-features'); + + if (featuresRes.status === 200) { + try { + const features = JSON.parse(featuresRes.body as string); + const flags = features.flags || features; + if (Array.isArray(flags) && flags.length > 0) { + const flagName = flags[0].name; + + const setFeatureRes = http.put( + `${baseUrl}/${tenantId}/features/${flagName}`, + JSON.stringify({ isEnabled: false }), + { headers, tags: { name: 'set-tenant-feature' } }, + ); + checkResponse(setFeatureRes, 'set-tenant-feature'); + + const delFeatureRes = http.del(`${baseUrl}/${tenantId}/features/${flagName}`, null, { + headers, + tags: { name: 'delete-tenant-feature' }, + }); + checkResponse(delFeatureRes, 'delete-tenant-feature', 204); + } + } catch { + // ignore + } + } + + const delRes = http.del(`${baseUrl}/${tenantId}`, null, { + headers, + tags: { name: 'delete-tenant' }, + }); + checkResponse(delRes, 'delete-tenant', 204); + + sleep(1); +} diff --git a/tests/k6/scenarios/users.ts b/tests/k6/scenarios/users.ts new file mode 100644 index 00000000..ac034f24 --- /dev/null +++ b/tests/k6/scenarios/users.ts @@ -0,0 +1,70 @@ +import { sleep } from 'k6'; +import http from 'k6/http'; +import { type AuthResult, authenticate, authHeaders } from '../lib/auth.ts'; +import { config, defaultThresholds, loadProfiles, tlsOptions } from '../lib/config.ts'; +import { checkResponse, randomString } from '../lib/helpers.ts'; + +const profile = __ENV.K6_PROFILE || 'smoke'; + +export const options = { + ...tlsOptions, + stages: loadProfiles[profile]?.stages || loadProfiles.smoke.stages, + thresholds: { + ...defaultThresholds, + 'http_req_duration{name:list-users}': ['p(95)<500'], + 'http_req_duration{name:create-user}': ['p(95)<800'], + 'http_req_duration{name:get-user}': ['p(95)<500'], + 'http_req_duration{name:update-user}': ['p(95)<500'], + 'http_req_duration{name:delete-user}': ['p(95)<500'], + 'http_req_duration{name:get-current-user}': ['p(95)<300'], + }, +}; + +export function setup(): AuthResult { + return authenticate(); +} + +export default function (auth: AuthResult) { + const headers = authHeaders(auth.accessToken); + const baseUrl = `${config.baseUrl}/api/users`; + + const listRes = http.get(baseUrl, { headers, tags: { name: 'list-users' } }); + checkResponse(listRes, 'list-users'); + + const meRes = http.get(`${baseUrl}/me`, { headers, tags: { name: 'get-current-user' } }); + checkResponse(meRes, 'get-current-user'); + + const email = `k6-${randomString(8)}@test.dev`; + const createRes = http.post( + baseUrl, + JSON.stringify({ + email, + displayName: `K6 User ${randomString(4)}`, + password: 'K6Test123!', + }), + { headers, tags: { name: 'create-user' } }, + ); + checkResponse(createRes, 'create-user', 201); + + if (createRes.status === 201) { + const userId = JSON.parse(createRes.body as string).id; + + const getRes = http.get(`${baseUrl}/${userId}`, { headers, tags: { name: 'get-user' } }); + checkResponse(getRes, 'get-user'); + + const updateRes = http.put( + `${baseUrl}/${userId}`, + JSON.stringify({ email, displayName: `K6 Updated ${randomString(4)}` }), + { headers, tags: { name: 'update-user' } }, + ); + checkResponse(updateRes, 'update-user'); + + const deleteRes = http.del(`${baseUrl}/${userId}`, null, { + headers, + tags: { name: 'delete-user' }, + }); + checkResponse(deleteRes, 'delete-user', 204); + } + + sleep(1); +} diff --git a/tests/k6/tsconfig.json b/tests/k6/tsconfig.json new file mode 100644 index 00000000..992638bd --- /dev/null +++ b/tests/k6/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true + }, + "include": ["lib/**/*.ts", "scenarios/**/*.ts"] +}