Files
EveryPublish/tools/web-smoke.mjs
T

228 lines
15 KiB
JavaScript

import { readFile } from 'node:fs/promises';
const base = process.env.EP_API_URL || 'http://127.0.0.1:8090';
const apiBase = `${base}/api/v1`;
function assert(condition, message) {
if (!condition) throw new Error(message);
}
async function request(path, options = {}, expectedStatus = 200) {
const response = await fetch(apiBase + path, options);
const text = await response.text();
let body;
try {
body = JSON.parse(text);
} catch {
throw new Error(`${path} returned non-JSON (${response.status}): ${text.slice(0, 160)}`);
}
if (response.status !== expectedStatus || (expectedStatus >= 200 && expectedStatus < 300 && body.code !== 0)) {
throw new Error(`${path} failed: HTTP ${response.status}, code ${body.code}, ${body.message || ''}`);
}
return body;
}
function json(body, token) {
return {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
};
}
async function waitFor(predicate, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const value = await predicate();
if (value) return value;
await new Promise((resolve) => setTimeout(resolve, 100));
}
throw new Error('timed out waiting for condition');
}
async function openBrowserWS(token) {
assert(typeof WebSocket === 'function', 'Node WebSocket is unavailable');
const socket = new WebSocket(`${base.replace(/^http/, 'ws')}/ws/browser?token=${encodeURIComponent(token)}`);
await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Browser WS open timeout')), 3000);
socket.addEventListener('open', () => {
clearTimeout(timer);
resolve();
});
socket.addEventListener('error', () => {
clearTimeout(timer);
reject(new Error('Browser WS open failed'));
});
});
return socket;
}
async function main() {
const health = await fetch(`${base}/health`);
assert(health.ok, `health failed: HTTP ${health.status}`);
const agentRoute = await fetch(`${apiBase}/agent/devices`);
assert(agentRoute.status === 404, `mock mode must hide Agent routes, got ${agentRoute.status}`);
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const email = `web-smoke-${suffix}@everypublish.local`;
const password = 'WebSmoke-pass-2026';
const registered = await request('/auth/register', json({ email, password, nickname: 'Web Smoke' }));
const token = registered.data.accessToken;
assert(token, 'registration did not return access token');
const loggedIn = await request('/auth/login', json({ email, password }));
assert(loggedIn.data.accessToken && loggedIn.data.refreshToken, 'login did not return token pair');
const refreshed = await request('/auth/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: loggedIn.data.refreshToken }),
});
assert(refreshed.data.accessToken && refreshed.data.refreshToken, 'refresh did not rotate token pair');
const me = await request('/auth/me', { headers: { Authorization: `Bearer ${token}` } });
assert(me.data.workspaceId && me.data.memberRole === 'owner', 'auth/me workspace role missing');
const runtime = await request('/runtime/status', { headers: { Authorization: `Bearer ${token}` } });
assert(runtime.data.executorMode && Array.isArray(runtime.data.adapters) && runtime.data.db && runtime.data.redis, 'runtime status incomplete');
const workspaces = await request('/workspaces', { headers: { Authorization: `Bearer ${token}` } });
assert(workspaces.data.list?.length === 1, 'default workspace missing');
const workspaceId = workspaces.data.list[0].id;
const originalWorkspaceName = workspaces.data.list[0].name;
await request(`/workspaces/${workspaceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: `${originalWorkspaceName} smoke` }) });
await request(`/workspaces/${workspaceId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ name: originalWorkspaceName }) });
const memberEmail = `web-smoke-member-${suffix}@everypublish.local`;
const memberRegistered = await request('/auth/register', json({ email: memberEmail, password: 'MemberSmoke-pass-2026', nickname: 'Member Smoke' }));
const memberToken = memberRegistered.data.accessToken;
const invitation = await request('/members/invite', json({ email: memberEmail, role: 'viewer' }, token));
await request('/members/join', json({ token: invitation.data.token }, memberToken));
const memberWorkspace = await request('/workspaces', { headers: { Authorization: `Bearer ${memberToken}` } });
await request(`/auth/switch-workspace/${workspaceId}`, json({}, memberToken));
const memberSwitched = await request(`/auth/switch-workspace/${workspaceId}`, json({}, memberToken));
await request('/accounts', json({ platform: 'douyin', remark: 'viewer forbidden' }, memberSwitched.data.accessToken), 403);
const memberRefreshed = await request('/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ refreshToken: memberSwitched.data.refreshToken }) });
const memberMe = await request('/auth/me', { headers: { Authorization: `Bearer ${memberRefreshed.data.accessToken}` } });
assert(memberMe.data.workspaceId === workspaceId, 'refresh lost selected workspace');
const memberRows = await request('/members', { headers: { Authorization: `Bearer ${token}` } });
const invitedMember = memberRows.data.list.find((item) => item.email === memberEmail);
assert(invitedMember?.role === 'viewer', 'member invitation/join failed');
await request(`/members/${invitedMember.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
await request('/tasks', json({ title: 'unauthorized smoke', accountIds: [1], materialIds: [1] }), 401);
const socket = await openBrowserWS(token);
const events = [];
socket.addEventListener('message', (event) => {
try {
events.push(JSON.parse(event.data));
} catch {
// Ignore malformed event and let the final assertion fail if needed.
}
});
const account = await request('/accounts', json({ platform: 'douyin', remark: 'web smoke' }, token));
const accountId = account.data.id;
const bind = await request(`/accounts/${accountId}/bind`, { ...json({}, token) });
const challengeId = bind.data.challengeId;
const bindAgain = await request(`/accounts/${accountId}/bind`, { ...json({}, token) });
assert(bindAgain.data.challengeId === challengeId, 'repeated bind created duplicate active challenge');
const challengeList = await request('/challenges', { headers: { Authorization: `Bearer ${token}` } });
const challenge = challengeList.data.list.find((item) => item.id === challengeId);
assert(challenge?.kind === 'qr' && challenge.qrUrl?.startsWith('mock://'), 'mock QR challenge was not created');
await request(`/accounts/${accountId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ remark: 'web smoke updated' }) });
await request(`/accounts/${accountId}/credentials`, json({ cookies: 'SESSDATA=mock; bili_jct=mock; DedeUserID=1' }, token), 409);
await request(`/accounts/${accountId}/check`, { ...json({}, token) }, 409);
await request(`/challenges/${challengeId}/solve`, json({}, token), 400);
await request(`/challenges/${challengeId}/solve`, json({ value: 'web-smoke-login' }, token));
const activeAccounts = await request('/accounts?status=active', { headers: { Authorization: `Bearer ${token}` } });
assert(activeAccounts.data.list.some((item) => item.id === accountId), 'account did not become active');
const secondAccount = await request('/accounts', json({ platform: 'douyin', remark: 'challenge lifecycle' }, token));
const secondAccountId = secondAccount.data.id;
const secondBind = await request(`/accounts/${secondAccountId}/bind`, { ...json({}, token) });
await request(`/challenges/${secondBind.data.challengeId}/suspend`, json({}, token));
let secondChallenge = (await request('/challenges', { headers: { Authorization: `Bearer ${token}` } })).data.list.find((item) => item.id === secondBind.data.challengeId);
assert(secondChallenge?.status === 'suspended', 'challenge suspend did not persist');
const resent = await request(`/challenges/${secondBind.data.challengeId}/resend`, json({}, token));
assert(resent.data.qrUrl?.startsWith('mock://'), 'mock challenge resend did not return QR');
await request(`/challenges/${secondBind.data.challengeId}/solve`, json({ value: 'web-smoke-resend' }, token));
const bytes = await readFile(new URL('../references/social-auto-upload/videos/demo.mp4', import.meta.url));
const form = new FormData();
form.append('file', new Blob([bytes], { type: 'video/mp4' }), 'demo.mp4');
const material = await request('/materials', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form });
const materialId = material.data.material.id;
assert(material.data.material.kind === 'video', 'mp4 upload was not classified as video');
const materialURL = await request(`/materials/${materialId}/url`, { headers: { Authorization: `Bearer ${token}` } });
const firstDownload = await fetch(materialURL.data.url);
assert(firstDownload.ok, `signed material URL failed: HTTP ${firstDownload.status}`);
await firstDownload.arrayBuffer();
const secondDownload = await fetch(materialURL.data.url);
assert(secondDownload.status === 404 || secondDownload.status === 410, `signed URL was reusable: HTTP ${secondDownload.status}`);
const previewURL = await request(`/materials/${materialId}/url`, { headers: { Authorization: `Bearer ${token}` } });
const previewDownload = await fetch(previewURL.data.url + '?inline=1');
assert(previewDownload.ok && (previewDownload.headers.get('content-type') || '').includes('video'), 'inline material preview failed');
await previewDownload.arrayBuffer();
const materialList = await request('/materials?kind=video&size=100', { headers: { Authorization: `Bearer ${token}` } });
assert(materialList.data.list.some((item) => item.id === materialId), 'material kind filter missed upload');
const task = await request('/tasks', json({
title: 'Web smoke task',
content: 'Web-only smoke test',
tags: ['smoke'],
accountIds: [accountId],
materialIds: [materialId],
}, token));
await request('/tasks', json({ title: 'Web smoke invalid schedule', content: 'invalid', accountIds: [accountId], materialIds: [materialId], scheduleAt: Date.now() - 1000 }, token), 400);
const taskId = task.data.id;
await request(`/tasks/${taskId}/submit`, { ...json({}, token) });
await request(`/tasks/${taskId}/approve`, { ...json({}, token) });
// The first local worker tick can be delayed by a cold MySQL/Go process.
// Give the mock executor enough room without changing its state semantics.
const finalTask = await waitFor(async () => {
const result = await request(`/tasks/${taskId}`, { headers: { Authorization: `Bearer ${token}` } });
return result.data.status === 'success' ? result.data : null;
}, 10000);
assert(finalTask.publishedUrls.includes('mock://'), 'mock result URL missing');
assert(finalTask.receipts.includes('mock executor completed'), 'mock receipt missing');
const timeline = await request(`/tasks/${taskId}/events`, { headers: { Authorization: `Bearer ${token}` } });
const statuses = timeline.data.list.map((event) => event.status);
assert(statuses.join('>') === 'draft>pending_review>queued>dispatched>running>success', `unexpected task timeline: ${statuses.join('>')}`);
await waitFor(() => events.some((event) => event.type === 'task.status' || event.type === 'challenge.status'), 3000);
const rejectedTask = await request('/tasks', json({ title: 'Web smoke reject', content: 'reject path', accountIds: [accountId], materialIds: [materialId] }, token));
await request(`/tasks/${rejectedTask.data.id}/submit`, { ...json({}, token) });
await request(`/tasks/${rejectedTask.data.id}/reject`, json({ note: '需要补充说明' }, token));
await request(`/tasks/${rejectedTask.data.id}/resubmit`, json({}, token));
await request(`/tasks/${rejectedTask.data.id}/cancel`, json({}, token));
const rejectedFinal = await request(`/tasks/${rejectedTask.data.id}`, { headers: { Authorization: `Bearer ${token}` } });
assert(rejectedFinal.data.status === 'cancelled', 'reject/resubmit/cancel flow failed');
const draftTask = await request('/tasks', json({ title: 'Web smoke delete', content: 'delete path', accountIds: [accountId], materialIds: [materialId] }, token));
await request(`/tasks/${draftTask.data.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
const scheduledTask = await request('/tasks', json({ title: 'Web smoke schedule', content: 'schedule path', accountIds: [accountId], materialIds: [materialId], scheduleAt: Date.now() + 3600000 }, token));
await request(`/tasks/${scheduledTask.data.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ title: 'Web smoke schedule', content: 'schedule path', tags: [], accountIds: [accountId], materialIds: [materialId], scheduleAt: null }) });
const immediateTask = await request(`/tasks/${scheduledTask.data.id}`, { headers: { Authorization: `Bearer ${token}` } });
assert(!immediateTask.data.scheduleAt, 'schedule clear did not persist');
const notifications = await request('/notifications', { headers: { Authorization: `Bearer ${token}` } });
assert(Array.isArray(notifications.data.list), 'notification list missing');
await request('/notifications/read-all', json({}, token));
const audits = await request('/audit-logs?size=20', { headers: { Authorization: `Bearer ${token}` } });
assert(Array.isArray(audits.data.list), 'audit log list missing');
const nextPassword = 'WebSmoke-next-2026';
await request('/auth/password', json({ currentPassword: password, newPassword: nextPassword }, token));
const relogin = await request('/auth/login', json({ email, password: nextPassword }));
assert(relogin.data.accessToken, 'changed password could not login');
await request(`/accounts/${secondAccountId}/unbind`, json({}, token));
await request(`/accounts/${secondAccountId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
await request(`/materials/${materialId}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
socket.close();
console.log(JSON.stringify({ ok: true, accountId, materialId, taskId, timeline: statuses, browserEvents: events.length, coverage: ['auth', 'workspace', 'members', 'runtime', 'permissions', 'account', 'challenge-resend', 'materials', 'task-success', 'task-reject-resubmit-cancel', 'task-schedule-edit', 'notifications', 'audit'] }));
}
main().catch((error) => {
console.error(`web-smoke failed: ${error.message}`);
process.exitCode = 1;
});