Files
91/tests/adminDriveForm.test.ts
T
nianzhibai 7e5e67697e feat: add GuangYaPan drive support
Implement a new GuangYaPan cloud drive integration across the backend, admin UI, playback proxy, and Spider91 migration flow.

Backend changes:\n- Add a GuangYaPan drive driver with token refresh, QR/device login support, directory listing, stream link resolution, directory creation, rename/delete operations, OSS multipart upload, and upload task polling.\n- Register GuangYaPan as a supported storage kind in configuration, catalog normalization, admin APIs, public drive labels, and 302 playback redirects.\n- Allow Spider91 crawler uploads to target GuangYaPan through a dedicated migration adapter.\n- Add scan, thumbnail, preview, and fingerprint cooldown handling for GuangYaPan based on explicit HTTP status codes, Retry-After values, and structured provider codes instead of natural-language message matching.\n- Tighten existing provider cooldown detectors so OneDrive, Google Drive, 115, PikPak, 123pan, Wopan, and media workers avoid treating arbitrary response text as a rate-limit signal.\n- Keep large videos eligible for preview generation unless the user disables preview generation.

Admin and tooling changes:\n- Add GuangYaPan as a selectable drive type with QR login UI and token/root-path credential fields.\n- Add crawler upload target support for GuangYaPan in the admin UI.\n- Add drive branding, labels, metadata display, and docs/config examples for GuangYaPan.\n- Include a standalone GuangYaPan QR login helper script for manual credential acquisition.

Tests:\n- Add GuangYaPan driver, QR login, proxy, admin API, crawler upload target, fingerprint, cooldown, and form coverage.\n- Update rate-limit tests to assert that message-only throttling text no longer starts cooldowns.\n- Cover explicit HTTP status parsing through shared drive helper tests.
2026-06-14 15:44:50 +08:00

406 lines
18 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const drivesPageSource = readFileSync(
new URL("../src/admin/DrivesPage.tsx", import.meta.url),
"utf8"
);
const driveComponentsSource = readFileSync(
new URL("../src/admin/drive/DriveComponents.tsx", import.meta.url),
"utf8"
);
const crawlerPageSource = readFileSync(
new URL("../src/admin/CrawlersPage.tsx", import.meta.url),
"utf8"
);
const adminLayoutSource = readFileSync(
new URL("../src/admin/AdminLayout.tsx", import.meta.url),
"utf8"
);
const appSource = readFileSync(
new URL("../src/App.tsx", import.meta.url),
"utf8"
);
const spider91UploadTargetSource = readFileSync(
new URL("../src/admin/drive/Spider91UploadTargetField.tsx", import.meta.url),
"utf8"
);
const driveFormSource = readFileSync(
new URL("../src/admin/drive/DriveForm.tsx", import.meta.url),
"utf8"
);
const adminCss = readFileSync(
new URL("../src/styles/admin.css", import.meta.url),
"utf8"
);
const apiSource = readFileSync(
new URL("../src/admin/api.ts", import.meta.url),
"utf8"
);
const constantsSource = readFileSync(
new URL("../src/admin/drive/constants.ts", import.meta.url),
"utf8"
);
const combinedSource = drivesPageSource + "\n" + driveFormSource + "\n" + constantsSource + "\n" + spider91UploadTargetSource;
function driveTypeOptions() {
const match = /const DRIVE_OPTIONS:\s*DriveOption\[]\s*=\s*\[([\s\S]*?)\];/.exec(
driveFormSource
);
assert.ok(match, "drive option card list should be present");
return Array.from(
match[1].matchAll(/\{\s*kind:\s*"([^"]+)",\s*label:\s*"([^"]+)"/g),
(option) => ({ value: option[1], label: option[2] })
);
}
function assertDriveTypeOption(value: string, label: string) {
assert.ok(
driveTypeOptions().some((option) => option.value === value && option.label === label),
`${value} drive type option should be present`
);
}
test("crawler sources are not selectable as storage drives", () => {
assert.ok(
!driveTypeOptions().some((option) => option.value === "spider91"),
"spider91 should not be a storage drive option"
);
assert.ok(
!driveTypeOptions().some((option) => option.value === "scriptcrawler"),
"scriptcrawler should not be a storage drive option"
);
});
test("spider91 upload target uses explicit local-save option instead of auto target", () => {
assert.match(combinedSource, /本地保存,不上传/);
assert.match(
combinedSource,
/d\.kind === "pikpak"[\s\S]*d\.kind === "p115"[\s\S]*d\.kind === "p123"[\s\S]*d\.kind === "onedrive"[\s\S]*d\.kind === "googledrive"[\s\S]*d\.kind === "wopan"[\s\S]*d\.kind === "guangyapan"/
);
assert.match(crawlerPageSource, /UPLOAD_TARGET_KINDS[\s\S]*"wopan"[\s\S]*"guangyapan"/);
assert.doesNotMatch(combinedSource, /自动:唯一/);
assert.doesNotMatch(combinedSource, /自动模式/);
assert.doesNotMatch(combinedSource, /较早的视频会上传到该云盘根目录下的 91 Spider 文件夹/);
});
test("spider91 upload target select uses an aligned custom arrow", () => {
assert.match(spider91UploadTargetSource, /className="admin-form-select-wrap"/);
assert.match(spider91UploadTargetSource, /className="admin-form-select"/);
assert.match(spider91UploadTargetSource, /className="admin-form-select__icon"/);
assert.match(adminCss, /\.admin-form__row \.admin-form-select\s*\{[^}]*appearance\s*:\s*none/s);
assert.match(
adminCss,
/\.admin-form-select__icon\s*\{[^}]*top\s*:\s*50%[^}]*right\s*:\s*12px[^}]*transform\s*:\s*translateY\(-50%\)/s
);
});
test("drive form hides root directory id for localstorage and spider91", () => {
assert.match(combinedSource, /<label[^>]*>根目录 ID<\/label>/);
assert.match(
combinedSource,
/usesRootDirectoryID\(kind:\s*Kind\):\s*boolean\s*\{\s*return kind !== "localstorage" && kind !== "spider91";\s*\}/
);
assert.match(combinedSource, /\{usesRootDirectoryID\(form\.kind\) && \(/);
assert.match(combinedSource, /\{usesRootDirectoryID\(d\.kind\) && \(/);
assert.match(combinedSource, /placeholder=\{rootIdPlaceholder\(form\.kind\)\}/);
assert.doesNotMatch(combinedSource, /扫描起点目录 ID/);
assert.doesNotMatch(combinedSource, /set\("scanRootId"/);
});
test("onedrive drive form only exposes required default-app fields", () => {
const match =
/case "onedrive":\s*return \[([\s\S]*?)\];\s*case "googledrive":/.exec(
combinedSource
);
assert.ok(match, "onedrive credential field block should be present");
const fields = match[1];
assert.match(fields, /key: "refresh_token"/);
assert.doesNotMatch(fields, /key: "access_token"/);
assert.doesNotMatch(fields, /key: "api_url_address"/);
assert.doesNotMatch(fields, /key: "region"/);
assert.doesNotMatch(fields, /key: "is_sharepoint"/);
assert.doesNotMatch(fields, /key: "site_id"/);
});
test("googledrive drive form supports online API and custom OAuth client modes", () => {
assertDriveTypeOption("googledrive", "Google Drive");
const match =
/case "googledrive":\s*return \[([\s\S]*?)\];\s*case "localstorage":/.exec(
combinedSource
);
assert.ok(match, "googledrive credential field block should be present");
const fields = match[1];
assert.match(fields, /key: "refresh_token"/);
assert.match(fields, /key: "use_online_api"/);
assert.match(fields, /type: "select"/);
assert.match(fields, /defaultValue: "true"/);
assert.match(fields, /OpenList 在线 API/);
assert.match(fields, /自建 Google OAuth 客户端/);
assert.match(fields, /key: "client_id"/);
assert.match(fields, /key: "client_secret"/);
assert.match(fields, /googleDriveUsesOnlineAPI\(creds\)/);
assert.doesNotMatch(fields, /key: "api_url_address"/);
assert.doesNotMatch(fields, /在线 API 模式填写 OpenList 获取的 refresh_token/);
assert.doesNotMatch(constantsSource, /请参考OpenList文档中关于谷歌云盘的配置方法。/);
assert.doesNotMatch(constantsSource, /选择自建 Google OAuth 客户端后,服务端会直接请求 Google OAuth token 接口续期。/);
assert.match(driveFormSource, /<select/);
assert.match(driveFormSource, /value=\{form\.creds\[f\.key\] \?\? f\.defaultValue \?\? ""\}/);
assert.match(driveFormSource, /className="admin-form-select"/);
assert.match(driveFormSource, /ChevronDown/);
assert.match(drivesPageSource, /googleDriveUseOnlineAPI/);
assert.match(apiSource, /googleDriveUseOnlineAPI\?: boolean/);
assert.doesNotMatch(fields, /key: "access_token"/);
});
test("pikpak drive form only exposes account login fields", () => {
const match =
/case "pikpak":\s*return \[([\s\S]*?)\];\s*case "wopan":/.exec(
combinedSource
);
assert.ok(match, "pikpak credential field block should be present");
const fields = match[1];
assert.match(fields, /key: "username"/);
assert.match(fields, /key: "password"/);
assert.doesNotMatch(fields, /key: "platform"/);
assert.doesNotMatch(fields, /key: "refresh_token"/);
assert.doesNotMatch(fields, /key: "captcha_token"/);
assert.doesNotMatch(fields, /key: "device_id"/);
assert.doesNotMatch(fields, /key: "disable_media_link"/);
});
test("guangyapan drive form exposes qr login and token fields", () => {
assertDriveTypeOption("guangyapan", "光鸭网盘");
assert.match(driveFormSource, /GuangYaPanQRCodeLogin/);
assert.match(driveFormSource, /form\.kind === "guangyapan"/);
assert.match(apiSource, /startGuangYaPanQRLogin/);
assert.match(apiSource, /getGuangYaPanQRStatus/);
const match =
/case "guangyapan":\s*return \[([\s\S]*?)\];\s*case "onedrive":/.exec(
combinedSource
);
assert.ok(match, "guangyapan credential field block should be present");
const fields = match[1];
assert.match(fields, /key: "root_path"/);
assert.match(fields, /key: "refresh_token"/);
assert.match(fields, /key: "access_token"/);
assert.doesNotMatch(fields, /key: "phone_number"/);
assert.doesNotMatch(fields, /key: "send_code"/);
assert.doesNotMatch(fields, /key: "verify_code"/);
assert.doesNotMatch(fields, /key: "captcha_token"/);
assert.doesNotMatch(fields, /key: "client_id"/);
assert.doesNotMatch(fields, /key: "device_id"/);
assert.match(combinedSource, /if \(kind === "guangyapan"\) return ""/);
});
test("localstorage drive form asks for a server directory path", () => {
assertDriveTypeOption("localstorage", "本地存储");
const match =
/case "localstorage":\s*return \[([\s\S]*?)\];\s*case "spider91":/.exec(
combinedSource
);
assert.ok(match, "localstorage credential field block should be present");
const fields = match[1];
assert.match(fields, /key: "path"/);
assert.match(fields, /label: "本地目录路径"/);
assert.match(combinedSource, /if \(kind === "localstorage"\) return "\/"/);
assert.match(combinedSource, /kind !== "localstorage" && kind !== "spider91"/);
});
test("drive type selector keeps primary source order", () => {
assert.deepEqual(driveTypeOptions(), [
{ value: "p115", label: "115 网盘" },
{ value: "p123", label: "123网盘" },
{ value: "pikpak", label: "PikPak" },
{ value: "guangyapan", label: "光鸭网盘" },
{ value: "onedrive", label: "OneDrive" },
{ value: "googledrive", label: "Google Drive" },
{ value: "localstorage", label: "本地存储" },
{ value: "quark", label: "夸克网盘" },
{ value: "wopan", label: "联通网盘" },
]);
});
test("crawler management is a separate admin section", () => {
assert.match(adminLayoutSource, /to="\/admin\/crawlers"/);
assert.match(adminLayoutSource, /admin-nav__title">爬虫管理/);
assert.match(adminLayoutSource, /admin-nav__icon"><SpiderIcon size=\{16\} \/>/);
assert.match(appSource, /path="crawlers" element=\{<CrawlersPage \/>/);
assert.match(crawlerPageSource, /export function CrawlersPage/);
assert.match(crawlerPageSource, /SpiderIcon/);
assert.match(crawlerPageSource, /添加爬虫/);
// 新设计:列表 + Modal 三步编辑器,删除确认走 ConfirmModal,任务进行中自动轮询
assert.match(crawlerPageSource, /CrawlerEditorModal/);
assert.match(crawlerPageSource, /ConfirmModal/);
assert.doesNotMatch(crawlerPageSource, /window\.confirm/);
assert.match(crawlerPageSource, /POLL_INTERVAL_MS/);
assert.match(crawlerPageSource, /api\.listCrawlers/);
assert.match(crawlerPageSource, /api\.listDrives/);
assert.match(crawlerPageSource, /api\.upsertCrawler/);
assert.match(crawlerPageSource, /api\.runCrawler/);
assert.match(crawlerPageSource, /api\.stopCrawlerTasks/);
assert.match(crawlerPageSource, /api\.deleteCrawler/);
assert.match(crawlerPageSource, /api\.importCrawlerScriptFile/);
assert.match(crawlerPageSource, /api\.importCrawlerScriptURL/);
assert.match(crawlerPageSource, /api\.testCrawlerScript/);
assert.match(crawlerPageSource, /type="file"/);
assert.match(crawlerPageSource, /链接导入/);
assert.match(crawlerPageSource, /测试脚本/);
assert.match(crawlerPageSource, /测试通过/);
assert.match(crawlerPageSource, /Spider91UploadTargetField/);
assert.match(crawlerPageSource, /uploadDriveId/);
assert.match(crawlerPageSource, /UPLOAD_TARGET_KINDS/);
assert.doesNotMatch(crawlerPageSource, /新建脚本/);
assert.doesNotMatch(crawlerPageSource, /爬虫 ID/);
assert.doesNotMatch(crawlerPageSource, /crawler-id/);
assert.doesNotMatch(crawlerPageSource, /crawler-name/);
// 脚本路径只读展示,不允许手动填写
assert.doesNotMatch(crawlerPageSource, /crawler-script-path/);
assert.doesNotMatch(crawlerPageSource, /Python 解释器/);
assert.doesNotMatch(crawlerPageSource, /自定义配置 JSON/);
assert.doesNotMatch(crawlerPageSource, /Bot/);
// 项目不再内置任何爬虫:不允许出现内置 91 预设
assert.doesNotMatch(crawlerPageSource, /builtin/);
assert.doesNotMatch(crawlerPageSource, /内置 91/);
assert.match(apiSource, /type AdminCrawler/);
assert.match(apiSource, /uploadDriveId\?: string/);
assert.match(apiSource, /"\/crawlers"/);
assert.match(apiSource, /"\/crawlers\/import-file"/);
assert.match(apiSource, /"\/crawlers\/import-url"/);
assert.match(apiSource, /"\/crawlers\/test-script"/);
assert.match(apiSource, /type CrawlerDryRunResult/);
assert.match(apiSource, /id\?: string/);
assert.match(apiSource, /new FormData\(\)/);
assert.doesNotMatch(driveFormSource, /scriptcrawler/);
});
test("drive cards use configured abbreviations and visible fallback icon colors", () => {
assert.match(constantsSource, /googledrive:\s*"GD"/);
assert.match(constantsSource, /function driveKindAbbr\(kind: string\)/);
assert.match(constantsSource, /\.slice\(0, 2\)\.toUpperCase\(\)/);
assert.match(drivesPageSource, /driveKindAbbr\(d\.kind\)/);
assert.match(adminCss, /\.admin-drive-card__brand-icon\s*\{[^}]*background:\s*var\(--accent\);/s);
assert.match(adminCss, /\.admin-drive-card__brand-icon\[data-kind="googledrive"\]\s*\{\s*background:\s*#4285f4;\s*\}/);
assert.match(adminCss, /\.admin-drive-card__brand-icon\[data-kind="guangyapan"\]\s*\{\s*background:\s*var\(--drive-guangyapan\);/);
});
test("drive management exposes stop task controls", () => {
assert.match(apiSource, /stopDriveTasks/);
assert.match(apiSource, /\/drives\/\$\{encodeURIComponent\(id\)\}\/tasks\/stop/);
assert.match(apiSource, /stopAllTasks/);
assert.match(apiSource, /"\/tasks\/stop"/);
assert.match(drivesPageSource, /is-stop/);
assert.match(drivesPageSource, /停止所有任务/);
assert.match(drivesPageSource, /停止所有网盘任务/);
});
test("drive rescan reports busy storage tasks instead of queueing duplicates", () => {
assert.match(apiSource, /accepted:\s*boolean;\s*message\?:\s*string/);
assert.match(apiSource, /scanGenerationStatus\?: DriveGenerationStatus/);
assert.match(drivesPageSource, /当前存储有正在进行的任务,请稍后重试/);
assert.match(drivesPageSource, /function isDriveBusy\(d: api\.AdminDrive\)/);
assert.match(drivesPageSource, /d\.scanGenerationStatus/);
assert.match(drivesPageSource, /status\?\.state \|\| "idle"/);
assert.match(drivesPageSource, /scanningDriveIdsRef\.current\.has\(d\.id\)/);
assert.match(drivesPageSource, /if \(!resp\.accepted\)/);
assert.doesNotMatch(drivesPageSource, /disabled=\{!!scanningDriveId\}/);
});
test("nightly scan duplicate trigger uses full-scan busy message", () => {
assert.match(apiSource, /status:\s*NightlyJobStatus;\s*message\?:\s*string/);
assert.match(drivesPageSource, /当前有全量扫描任务正在进行,请稍后重试/);
assert.match(drivesPageSource, /resp\.message \|\| NIGHTLY_BUSY_MESSAGE/);
assert.match(constantsSource, /当前有全量扫描任务正在进行,请稍后重试/);
});
test("drive generation panel shows scan or crawler status first", () => {
assert.match(driveComponentsSource, /label=\{d\.kind === "spider91" \? "已废弃" : "扫盘"\}/);
assert.match(driveComponentsSource, /status=\{d\.scanGenerationStatus\}/);
assert.match(driveComponentsSource, /showCounts=\{false\}/);
assert.match(driveComponentsSource, /status\?\.scannedCount/);
assert.match(driveComponentsSource, /预计新增/);
assert.match(apiSource, /scannedCount:\s*number/);
assert.match(apiSource, /addedCount:\s*number/);
assert.match(constantsSource, /if \(state === "scanning"\) return "扫盘中"/);
});
test("legacy spider91 storage is disabled in drive management", () => {
assert.match(drivesPageSource, /91Spider 不再支持通过网盘运行,请到爬虫管理添加爬虫脚本/);
assert.match(drivesPageSource, /disabled=\{d\.kind === "spider91"\}/);
assert.match(drivesPageSource, /已废弃,请到爬虫管理添加/);
assert.match(constantsSource, /91Spider 不再支持通过网盘添加或编辑/);
});
test("drive detail selection is stored in the URL history", () => {
assert.match(drivesPageSource, /useSearchParams/);
assert.match(drivesPageSource, /searchParams\.get\("drive"\)/);
assert.match(drivesPageSource, /function openDriveDetail\(id: string\)/);
assert.match(drivesPageSource, /next\.set\("drive", id\)/);
assert.match(drivesPageSource, /function closeDriveDetail/);
assert.match(drivesPageSource, /next\.delete\("drive"\)/);
assert.doesNotMatch(drivesPageSource, /setSelectedDriveId/);
});
test("drive discard confirmation matches delete confirmation modal styling", () => {
const discardModals = Array.from(
drivesPageSource.matchAll(/<ConfirmModal[\s\S]*?title="放弃未保存更改"[\s\S]*?\/>/g),
(match) => match[0]
);
assert.equal(discardModals.length, 2);
for (const modal of discardModals) {
assert.match(modal, /danger/);
assert.match(modal, /centerMessage/);
assert.match(modal, /modalClassName="admin-modal--delete-confirm"/);
}
});
test("new drive type selection alone is not treated as unsaved config", () => {
assert.match(
drivesPageSource,
/const formDirty = form\.id\s*\?\s*!sameForm\(form, initialForm\)\s*:\s*hasCreateFormChanges\(form, initialForm\);/
);
assert.match(drivesPageSource, /function handleCreateFormChange\(nextForm: FormState\)/);
assert.match(
drivesPageSource,
/if \(!nextForm\.id && !hasCreateFormChanges\(nextForm, initialForm\)\) \{\s*setInitialForm\(nextForm\);/
);
assert.match(drivesPageSource, /onChange=\{handleCreateFormChange\}/);
const match = /function hasCreateFormChanges\(form: FormState, initial: FormState\): boolean \{([\s\S]*?)\n\}/.exec(
drivesPageSource
);
assert.ok(match, "create form dirty helper should be present");
const helper = match[1];
assert.match(helper, /form\.name\.trim\(\) !== ""/);
assert.match(helper, /form\.rootId\.trim\(\) !== ""/);
assert.match(helper, /form\.spider91UploadDriveId !== initial\.spider91UploadDriveId/);
assert.match(helper, /Object\.values\(form\.creds\)\.some/);
assert.doesNotMatch(helper, /form\.kind/);
});
test("drive generation actions can resume pending work after stop", () => {
assert.match(driveComponentsSource, /thumbnailPendingCount/);
assert.match(driveComponentsSource, /teaserPendingCount/);
assert.match(driveComponentsSource, /fingerprintPendingCount/);
assert.match(driveComponentsSource, /继续生成封面/);
assert.match(driveComponentsSource, /继续生成预览视频/);
assert.match(driveComponentsSource, /继续生成指纹/);
});
test("drive cards label fingerprint count as video fingerprint count", () => {
assert.match(driveComponentsSource, /视频指纹数 \(就绪\/失败\)/);
assert.doesNotMatch(driveComponentsSource, />指纹数 \(就绪\/失败\)</);
});