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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion strr-host-pm-web/tests/stress/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,7 @@
STRR_API_URL=
# Account ID of the user that will be used to perform API requests (submit application, etc)
ACCOUNT_ID=
# IDIR Account ID of the user that will be used to perform API requests
IDIR_ACCOUNT_ID=
# Bearer access token for authorizing API requests (could be obtained from /token response in UI)
ACCESS_TOKEN=
ACCESS_TOKEN=
16 changes: 10 additions & 6 deletions strr-host-pm-web/tests/stress/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ Fill in the required values into `.env`:
| Variable | Required | Description |
| --- | --- | --- |
| `STRR_API_URL` | Yes | Base URL of the STRR API (e.g. `https://strr-api-dev-...run.app/`) |
| `ACCOUNT_ID` | Yes | BC Registries account ID for the `Account-Id` header |
| `ACCOUNT_ID` | Yes | BCSC account ID for the `Account-Id` header |
| `IDIR_ACCOUNT_ID` | Yes | IDIR account ID for the `Account-Id` header (used by examiner scripts) |
| `ACCESS_TOKEN` | Yes | Bearer token for the `Authorization` header |

## Running the Tests
Expand All @@ -32,9 +33,12 @@ Replace `<script>.js` with the name of the test script you want to run.

### `submit-applications.js`

#### Default Configuration
Submits a `POST /applications` request with a mock STRR host registration payload.

| Setting | Value |
| --- | --- |
| Virtual users | 1 |
| Iterations | 1 |
### `search-applications.js`

Simulates searching applications via `GET /applications` with various filter combinations (status, address, record number, assignee, draft registration toggle).

### `search-registrations.js`

Simulates searching registrations via `GET /registrations/search` with various filter combinations (status, approval method, NOC status, set-aside flag, text search).
171 changes: 171 additions & 0 deletions strr-host-pm-web/tests/stress/search-applications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/**
* Simulates searching applications via GET /applications with various filter combinations.
*/
import http from 'k6/http'
import { check, group } from 'k6'
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js'

const BASE_URL = __ENV.STRR_API_URL

const NUMBER_OF_CONCURRENT_USERS = 1

// Default params sent when the applications table is loaded/reset to defaults:
// status=FULL_REVIEW&sortBy=application_date&sortOrder=asc&address=&recordNumber=
// &assignee=&includeDraftRegistration=false&applicationsOnly=true
const DEFAULT_PARAMS = {
status: ['FULL_REVIEW'],
sortBy: 'application_date',
sortOrder: 'asc',
address: '',
recordNumber: '',
assignee: '',
includeDraftRegistration: false,
applicationsOnly: true
}

// Options docs: https://grafana.com/docs/k6/latest/using-k6/k6-options/
export const options = {
vus: NUMBER_OF_CONCURRENT_USERS, // number of virtual users to run concurrently
iterations: NUMBER_OF_CONCURRENT_USERS, // the total number of times the default function runs across all VUs combined

// Thresholds docs: https://grafana.com/docs/k6/latest/using-k6/thresholds/
thresholds: {
http_req_duration: ['p(95)<3000'], // 95% of requests must complete within 3s
http_req_failed: ['rate<0.05'], // error rate must stay below 5%
checks: ['rate>0.99'] // 99% of all checks must pass
}
}

export function setup () {
const token = __ENV.ACCESS_TOKEN
if (!token) {
throw new Error('ACCESS_TOKEN is not set. Provide it in your .env file before running this script.')
}
return { token }
}

/**
* Build a query string from a params object, supporting array values
* by repeating the key (e.g. status=FULL_REVIEW&status=DRAFT).
*/
function buildQueryString (params) {
const parts = []
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) { continue }
if (Array.isArray(value)) {
for (const v of value) {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`)
}
} else {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
}
}
return parts.length > 0 ? `?${parts.join('&')}` : ''
}

function searchApplications (token, params) {
const headers = {
'Account-Id': __ENV.IDIR_ACCOUNT_ID,
Authorization: `Bearer ${token}`
}
const query = buildQueryString({
limit: 50,
page: 1,
...params
})
return http.get(`${BASE_URL}/applications${query}`, { headers })
}

function parseBody (res) {
try { return JSON.parse(res.body) } catch { return null }
}

function checkSearchResponse (res, body) {
check(res, {
'status is 200': r => r.status === 200
})
check(body, {
'response parses as JSON': b => b !== null,
'applications array exists': b => Array.isArray(b?.applications),
'total count exists': b => typeof b?.total === 'number'
})
}

export default function (data) {

Check warning on line 94 in strr-host-pm-web/tests/stress/search-applications.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The function should be named.

See more on https://sonarcloud.io/project/issues?id=bcgov_STRR&issues=AZz9VwLuklsaWFiTOr93&open=AZz9VwLuklsaWFiTOr93&pullRequest=1104
const { token } = data

// Baseline (default filters reset)

group('Default search (default filters)', () => {
const res = searchApplications(token, DEFAULT_PARAMS)
const body = parseBody(res)
checkSearchResponse(res, body)
})

// Status filter only searches

group('Status filter — FULL_REVIEW', () => {
const res = searchApplications(token, { status: ['FULL_REVIEW'], applicationsOnly: true })
const body = parseBody(res)
checkSearchResponse(res, body)
})

group('Status filter — DRAFT', () => {
const res = searchApplications(token, { status: ['DRAFT'], applicationsOnly: true })
const body = parseBody(res)
checkSearchResponse(res, body)
})

// Text search on top of default params

group('Text search — address', () => {
const res = searchApplications(token, { ...DEFAULT_PARAMS, address: 'victoria' })
const body = parseBody(res)
checkSearchResponse(res, body)
})

group('Text search — application number (as text)', () => {
const res = searchApplications(token, { ...DEFAULT_PARAMS, text: '31333398143962' })
const body = parseBody(res)
checkSearchResponse(res, body)
})

group('Application number search (as recordNumber)', () => {
const res = searchApplications(token, { ...DEFAULT_PARAMS, recordNumber: '31333398143962' })
const body = parseBody(res)
checkSearchResponse(res, body)
})

// Toggle flags

group('Exclude draft registrations', () => {
const res = searchApplications(token, { ...DEFAULT_PARAMS, includeDraftRegistration: false })
const body = parseBody(res)
checkSearchResponse(res, body)
})

group('All applications (not applicationsOnly)', () => {
const res = searchApplications(token, { ...DEFAULT_PARAMS, applicationsOnly: false })
const body = parseBody(res)
checkSearchResponse(res, body)
})

// sleep(1) // set time in seconds between test runs
}

// Summary docs: https://grafana.com/docs/k6/latest/results-output/end-of-test/custom-summary/
export function handleSummary (data) {
// data.metrics.checks is undefined if no checks ran at all
const passes = data.metrics.checks?.values.passes ?? 0
const fails = data.metrics.checks?.values.fails ?? 0
const total = passes + fails
const pct = total > 0 ? ((passes / total) * 100).toFixed(1) : '0.0'

// overall result printed before the detailed breakdown
const summary = `[${fails === 0 ? 'PASSED' : 'FAILED'}] ${passes}/${total} checks passed (${pct}%)\n`

// textSummary reproduces k6's default output (groups, metrics, thresholds)
const defaultSummary = textSummary(data, { indent: ' ', enableColors: true })

return { stdout: summary + '\n' + defaultSummary }
}
183 changes: 183 additions & 0 deletions strr-host-pm-web/tests/stress/search-registrations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Simulates searching registrations via GET /registrations/search with various filter combinations.
*/
import http from 'k6/http'
import { check, group } from 'k6'
import { textSummary } from 'https://jslib.k6.io/k6-summary/0.0.2/index.js'

const BASE_URL = __ENV.STRR_API_URL

const NUMBER_OF_CONCURRENT_USERS = 1

// Default params sent when the registrations table is loaded/reset to defaults:
// status=ACTIVE&recordNumber=&approvalMethod=PROVISIONALLY_APPROVED
// &approvalMethod=PROVISIONAL_REVIEW&nocStatus=NOC_PENDING&reviewRenew=true
const DEFAULT_PARAMS = {
status: ['ACTIVE'],
recordNumber: '',
approvalMethod: ['PROVISIONALLY_APPROVED', 'PROVISIONAL_REVIEW'],
nocStatus: ['NOC_PENDING'],
reviewRenew: true
}

// Options docs: https://grafana.com/docs/k6/latest/using-k6/k6-options/
export const options = {
vus: NUMBER_OF_CONCURRENT_USERS, // number of virtual users to run concurrently
iterations: NUMBER_OF_CONCURRENT_USERS, // the total number of times the default function runs across all VUs combined

// Thresholds docs: https://grafana.com/docs/k6/latest/using-k6/thresholds/
thresholds: {
http_req_duration: ['p(95)<3000'], // 95% of requests must complete within 3s
http_req_failed: ['rate<0.05'], // error rate must stay below 5%
checks: ['rate>0.99'] // 99% of all checks must pass
}
}

export function setup () {
const token = __ENV.ACCESS_TOKEN
if (!token) {
throw new Error('ACCESS_TOKEN is not set. Provide it in your .env file before running this script.')
}
return { token }
}

/**
* Build a query string from a params object, supporting array values
* by repeating the key (e.g. status=ACTIVE&status=SUSPENDED).
*/
function buildQueryString (params) {
const parts = []
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) { continue }
if (Array.isArray(value)) {
for (const v of value) {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(v)}`)
}
} else {
parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
}
}
return parts.length > 0 ? `?${parts.join('&')}` : ''
}

function searchRegistrations (token, params) {
const headers = {
'Account-Id': __ENV.IDIR_ACCOUNT_ID,
Authorization: `Bearer ${token}`
}
const query = buildQueryString({
sortOrder: 'asc',
limit: 50,
page: 1,
...params
})
return http.get(`${BASE_URL}/registrations/search${query}`, { headers })
}

function parseBody (res) {
try { return JSON.parse(res.body) } catch { return null }
}

function checkSearchResponse (res, body) {
check(res, {
'status is 200': r => r.status === 200
})
check(body, {
'response parses as JSON': b => b !== null,
'registrations array exists': b => Array.isArray(b?.registrations),
'total count exists': b => typeof b?.total === 'number'
})
}

export default function (data) {

Check warning on line 92 in strr-host-pm-web/tests/stress/search-registrations.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The function should be named.

See more on https://sonarcloud.io/project/issues?id=bcgov_STRR&issues=AZz9VwIsklsaWFiTOr92&open=AZz9VwIsklsaWFiTOr92&pullRequest=1104
const { token } = data

// Baseline (default filters reset)

group('Default search (default filters)', () => {
const res = searchRegistrations(token, DEFAULT_PARAMS)
const body = parseBody(res)
checkSearchResponse(res, body)
})

// Text search on top of default params

group('Text search — city name', () => {
const res = searchRegistrations(token, { ...DEFAULT_PARAMS, text: 'vancouver' })
const body = parseBody(res)
checkSearchResponse(res, body)
})

group('Text search — first name', () => {
const res = searchRegistrations(token, { ...DEFAULT_PARAMS, text: 'Delbert' })
const body = parseBody(res)
checkSearchResponse(res, body)
})

group('Text search — registration number', () => {
const res = searchRegistrations(token, { ...DEFAULT_PARAMS, text: 'H768267365' })
const body = parseBody(res)
checkSearchResponse(res, body)
})

group('Text search — active only', () => {
const res = searchRegistrations(token, { ...DEFAULT_PARAMS, status: ['ACTIVE'], text: 'vancouver' })
const body = parseBody(res)
checkSearchResponse(res, body)
check(body, {
'all results have ACTIVE status': b =>
Array.isArray(b?.registrations) &&
b.registrations.every(r => r?.status === 'ACTIVE')
})
})

// Status filter only searches (no Find in registration...)

group('Status filter — ACTIVE', () => {
const res = searchRegistrations(token, { status: ['ACTIVE'] })
const body = parseBody(res)
checkSearchResponse(res, body)
check(body, {
'all results have ACTIVE status': b =>
Array.isArray(b?.registrations) &&
b.registrations.every(r => r?.status === 'ACTIVE')
})
})

group('Approval method filter — AUTO_APPROVED', () => {
const res = searchRegistrations(token, { approvalMethod: ['AUTO_APPROVED'] })
const body = parseBody(res)
checkSearchResponse(res, body)
})

group('NOC status filter — NOC_PENDING', () => {
const res = searchRegistrations(token, { nocStatus: ['NOC_PENDING'] })
const body = parseBody(res)
checkSearchResponse(res, body)
})

group('Set-aside filter', () => {
const res = searchRegistrations(token, { isSetAside: true })
const body = parseBody(res)
checkSearchResponse(res, body)
})

// sleep(1) // set time in seconds between test runs
}

// Summary docs: https://grafana.com/docs/k6/latest/results-output/end-of-test/custom-summary/
export function handleSummary (data) {
// data.metrics.checks is undefined if no checks ran at all
const passes = data.metrics.checks?.values.passes ?? 0
const fails = data.metrics.checks?.values.fails ?? 0
const total = passes + fails
const pct = total > 0 ? ((passes / total) * 100).toFixed(1) : '0.0'

// overall result printed before the detailed breakdown
const summary = `[${fails === 0 ? 'PASSED' : 'FAILED'}] ${passes}/${total} checks passed (${pct}%)\n`

// textSummary reproduces k6's default output (groups, metrics, thresholds)
const defaultSummary = textSummary(data, { indent: ' ', enableColors: true })

return { stdout: summary + '\n' + defaultSummary }
}
Loading