server: 平台抽象层+bilibili、服务端加密凭据库、角色中间件;web: 精简页面/路由、macOS 客户端(Swift)与多份方案文档;移除误入库的编译产物
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user