Files
photo-live/loadtest/lib/helpers.js
T

221 lines
11 KiB
JavaScript
Raw Normal View History

// loadtest/lib/helpers.js —— k6 公共助手(P5-prep Task 3)。
// 被 read-feed.js / upload-burst.js 复用:登录 / 建相册 / 上传全链 / 公开读
// 分页 / 点赞批次 / 浏览上报 / 公开可见轮询。端点与请求体形态对齐
// scripts/prod-smoke.sh(契约权威 = packages/contracts/openapi.yaml)。
//
// 内置测试图与 prod-smoke.sh 同一张:800x600 JPEG(quality 55,3118 字节),
// base64 内嵌(下方 JPEG_B64 由 scripts/prod-smoke.sh 同步生成,勿手改),
// k6 侧用 k6/encoding 解码为二进制。刻意不用 1x1——避免变体流水线踩最小
// 尺寸边界。
import http from 'k6/http';
import { sleep } from 'k6';
import encoding from 'k6/encoding';
export const JPEG_B64 = '/9j/2wBDAA4KCw0LCQ4NDA0QDw4RFiQXFhQUFiwgIRokNC43NjMuMjI6QVNGOj1OPjIySGJJTlZYXV5dOEVmbWVabFNbXVn/2wBDAQ8QEBYTFioXFypZOzI7WVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVn/wAARCAJYAyADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCsAkKIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
/** 解码一次,全脚本复用(ArrayBuffer 可直接作 k6 http 请求体)。 */
export const JPEG_BYTES = encoding.b64decode(JPEG_B64, true);
// ── env 参数读取(--env KEY=VALUE → __ENV.KEY;缺省回退)──
export function envStr(name, fallback) {
const v = __ENV[name];
return v === undefined || v === '' ? fallback : v;
}
export function envInt(name, fallback) {
const raw = __ENV[name];
if (raw === undefined || raw === '') {
return fallback;
}
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export function envFloat(name, fallback) {
const raw = __ENV[name];
if (raw === undefined || raw === '') {
return fallback;
}
const n = Number.parseFloat(raw);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
/** 压测内唯一键(clientTaskId/batchId/requestId 等幂等键;时间戳+随机足够)。 */
export function uniqId(prefix) {
return `${prefix}-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
}
// ── 认证与相册 ──
export function authHeaders(token) {
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}
/** 密码登录 → sessionToken(响应体 {sessionToken,...},同 prod-smoke ②)。 */
export function login(base, account, password) {
const res = http.post(
`${base}/v1/auth/password`,
JSON.stringify({ account, password }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_password' } },
);
if (res.status !== 200) {
throw new Error(`登录失败 HTTP ${res.status}: ${res.body}`);
}
const token = res.json('sessionToken');
if (!token) {
throw new Error(`登录响应缺 sessionToken: ${res.body}`);
}
return token;
}
/** 建时间戳相册 → { albumId, publicToken }(同 prod-smoke ③)。 */
export function createAlbum(base, token, title, description) {
const res = http.post(
`${base}/v1/albums`,
JSON.stringify({
translations: [
{ locale: 'zh-CN', title, description: description || 'k6 loadtest fixture' },
],
defaultLocale: 'zh-CN',
startAt: new Date().toISOString(),
}),
{ headers: authHeaders(token), tags: { name: 'albums_create' } },
);
if (res.status !== 201) {
throw new Error(`建相册失败 HTTP ${res.status}: ${res.body}`);
}
return { albumId: res.json('id'), publicToken: res.json('publicToken') };
}
// ── 上传全链(presign → OSS PUT → complete)──
/**
* 单照片全链上传。返回逐步耗时(毫秒,k6 timings)与 id,供脚本各自打点:
* { photoId, uploadId, timings: { presign, put, complete, chainMs } }
* 任一步非预期状态码即抛错(k6 计一次 error 并终止单迭代)。
*/
export function uploadPhoto(base, token, albumId, clientTaskId) {
const started = Date.now();
const presign = http.post(
`${base}/v1/uploads`,
JSON.stringify({
albumId,
clientTaskId,
filename: 'k6.jpg',
bytes: JPEG_BYTES.byteLength,
contentType: 'image/jpeg',
}),
{ headers: authHeaders(token), tags: { name: 'uploads_presign' } },
);
if (presign.status !== 201) {
throw new Error(`presign 失败 HTTP ${presign.status}: ${presign.body}`);
}
const uploadId = presign.json('uploadId');
const photoId = presign.json('photoId');
const url = presign.json('url');
if (!uploadId || !url) {
throw new Error(`presign 响应缺 uploadId/url: ${presign.body}`);
}
// PUT 目标是 presigned 对象存储 URL(staging=MinIO / 生产=OSS),非被测 API 域。
const put = http.put(url, JPEG_BYTES, {
headers: { 'Content-Type': 'image/jpeg' },
tags: { name: 'oss_put' },
});
if (put.status < 200 || put.status > 299) {
throw new Error(`OSS PUT 失败 HTTP ${put.status}`);
}
const complete = http.post(
`${base}/v1/uploads/${uploadId}/complete`,
'',
{ headers: authHeaders(token), tags: { name: 'uploads_complete' } },
);
if (complete.status !== 200) {
throw new Error(`complete 失败 HTTP ${complete.status}: ${complete.body}`);
}
return {
photoId,
uploadId,
timings: {
presign: presign.timings.duration,
put: put.timings.duration,
complete: complete.timings.duration,
chainMs: Date.now() - started,
},
};
}
// ── 观众端四类操作(ov 域公开端点;亦可打 cn 做对照)──
/**
* 公开照片列表一页(契约:limit 1..60 缺省 30;cursor 不透明需 encode;
* sort=latest|hot 仅 ov 实现非法值 400)。tag 参数让脚本能按 name 分组统计。
*/
export function fetchFeedPage(base, albumToken, options) {
const opts = options || {};
let qs = `limit=${opts.limit || 30}`;
if (opts.cursor) {
qs += `&cursor=${encodeURIComponent(opts.cursor)}`;
}
if (opts.sort) {
qs += `&sort=${opts.sort}`;
}
return http.get(`${base}/v1/public/albums/${albumToken}/photos?${qs}`, {
tags: { name: opts.tag || 'list_public_photos' },
});
}
/** 浏览上报(匿名;visitorId 客户端持久 8..128 字符,requestId 幂等键)。 */
export function reportVisit(base, albumToken, visitorId, requestId) {
return http.post(
`${base}/v1/public/albums/${albumToken}/visits`,
JSON.stringify({ visitorId, requestId }),
{
headers: { 'Content-Type': 'application/json' },
tags: { name: 'visit_report' },
},
);
}
/** 点赞批次(匿名;sessionId 限速键 / batchId 幂等键 / delta 1..20 整数)。 */
export function likeBatch(base, albumToken, photoId, sessionId, batchId, delta) {
return http.post(
`${base}/v1/public/albums/${albumToken}/photos/${photoId}/likes`,
JSON.stringify({ sessionId, batchId, delta }),
{
headers: { 'Content-Type': 'application/json' },
tags: { name: 'like_batch' },
},
);
}
/**
* 轮询公开列表直到 photoId 可见(worker 发布事务异步 + cn→ov 推送 2s 间隔;
* 同 prod-smoke ④ 口径)。返回 { found, waitedMs, polls }。
*/
export function pollUntilVisible(
base,
albumToken,
photoId,
timeoutSec,
intervalSec,
tag,
) {
const started = Date.now();
let polls = 0;
for (;;) {
polls += 1;
const res = fetchFeedPage(base, albumToken, { limit: 30, tag: tag || 'public_read_poll' });
if (res.status === 200) {
const items = res.json('items');
if (Array.isArray(items) && items.some((it) => it.photoId === photoId)) {
return { found: true, waitedMs: Date.now() - started, polls };
}
}
if (Date.now() - started > timeoutSec * 1000) {
return { found: false, waitedMs: Date.now() - started, polls };
}
sleep(intervalSec);
}
}