diff --git a/src/backend/drivers/teambition/driver.test.ts b/src/backend/drivers/teambition/driver.test.ts new file mode 100644 index 00000000..06268000 --- /dev/null +++ b/src/backend/drivers/teambition/driver.test.ts @@ -0,0 +1,170 @@ +import test from "node:test" +import assert from "node:assert/strict" +import { DriverTeambition } from "./driver" +import { DriverTeambitionAddition } from "./types" + +/** + * 对齐 Go drivers/teambition 的回归测试: + * 1) root_folder_id 对齐 Go driver.RootID(留空才用项目根目录) + * 2) move/copy 的目标目录必须由「目标物理路径」推导,不能用挂载后的虚拟路径 + * 3) 非 2xx 且空响应体时必须报错,不能静默返回空列表 + */ + +const BASE = "https://www.teambition.com" + +interface Fixture { + folders?: Record + works?: Record +} + +function installFetch(fixture: Fixture) { + const original = globalThis.fetch + const calls: { method: string; url: string; body?: any }[] = [] + + globalThis.fetch = (async (input: any, init?: any) => { + const url = String(input) + const method = init?.method || "GET" + calls.push({ + method, + url, + body: typeof init?.body === "string" ? init.body : undefined, + }) + const parsed = new URL(url) + const parentId = parsed.searchParams.get("_parentId") || "" + + if (parsed.pathname === "/api/collections") { + return new Response(JSON.stringify(fixture.folders?.[parentId] || []), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + if (parsed.pathname === "/api/works") { + return new Response(JSON.stringify(fixture.works?.[parentId] || []), { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + if (parsed.pathname.endsWith("/move") || parsed.pathname.endsWith("/fork")) { + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + if (parsed.pathname === "/api/collections/COL-A") { + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }) + } + return new Response("", { status: 404 }) + }) as typeof fetch + + return { + calls, + restore: () => { + globalThis.fetch = original + }, + } +} + +const ADDITION: DriverTeambitionAddition = { + region: "china", + cookie: "tb_cookie=1", + project_id: "PROJ", +} + +test("根目录使用 root_folder_id(对齐 Go RootID)", async () => { + const fx = installFetch({ + folders: { + RID: [{ _id: "C1", title: "docs", updated: "2024-01-01T00:00:00.000Z" }], + }, + }) + try { + const driver = new DriverTeambition({ ...ADDITION, root_folder_id: "RID" }) + const items = await driver.list("/", "/") + assert.equal(items.length, 1) + assert.equal(items[0].name, "docs") + assert.equal(items[0].sign, "C1") + + const collectionCalls = fx.calls.filter((c) => c.url.includes("/api/collections")) + assert.equal(collectionCalls.length, 1) + assert.equal( + new URL(collectionCalls[0].url).searchParams.get("_parentId"), + "RID", + ) + } finally { + fx.restore() + } +}) + +test("未配置 root_folder_id 时使用项目根目录(空 _parentId)", async () => { + const fx = installFetch({ folders: { "": [] }, works: { "": [] } }) + try { + const driver = new DriverTeambition(ADDITION) + const items = await driver.list("/", "/") + assert.deepEqual(items, []) + const first = new URL(fx.calls[0].url) + assert.equal(first.searchParams.get("_parentId"), "") + assert.equal(first.searchParams.get("_projectId"), "PROJ") + assert.equal(first.searchParams.get("order"), "fileNameAsc") + } finally { + fx.restore() + } +}) + +test("move 以目标物理路径的父目录作为目标,而非虚拟挂载路径", async () => { + const fx = installFetch({ + folders: { + RID: [ + { _id: "A", title: "a", updated: "2024-01-01T00:00:00.000Z" }, + { _id: "B", title: "b", updated: "2024-01-01T00:00:00.000Z" }, + ], + A: [], + B: [], + }, + works: { + A: [{ _id: "W1", fileName: "f.txt", fileSize: 3, updated: "2024-01-01T00:00:00.000Z" }], + B: [], + }, + }) + try { + const driver = new DriverTeambition({ ...ADDITION, root_folder_id: "RID" }) + await driver.move( + "/mnt/a", + "/mnt/b", + ["f.txt"], + "/a/f.txt", + "/b/f.txt", + ) + const moveCall = fx.calls.find((c) => c.url.includes("/api/works/W1/move")) + assert.ok(moveCall, `未发出 move 请求: ${JSON.stringify(fx.calls)}`) + assert.equal(moveCall!.method, "PUT") + assert.deepEqual(JSON.parse(moveCall!.body || "{}"), { _parentId: "B" }) + // 不应把挂载前缀 "mnt" 当作项目内目录去解析 + assert.equal( + fx.calls.some((c) => c.url.includes("mnt")), + false, + ) + } finally { + fx.restore() + } +}) + +test("非 2xx 且空响应体时报错,不静默返回空目录", async () => { + const original = globalThis.fetch + globalThis.fetch = (async () => new Response("", { status: 401 })) as typeof fetch + try { + const driver = new DriverTeambition(ADDITION) + await assert.rejects(() => driver.list("/", "/"), /HTTP 401/) + } finally { + globalThis.fetch = original + } +}) + +test("上传在 Worker 运行时明确报不支持", async () => { + const driver = new DriverTeambition(ADDITION) + await assert.rejects( + () => driver.put("/a", "/a/x.txt", new Uint8Array([1])), + /not supported/, + ) +}) diff --git a/src/backend/drivers/teambition/driver.ts b/src/backend/drivers/teambition/driver.ts index f79a3ca2..aed3107f 100644 --- a/src/backend/drivers/teambition/driver.ts +++ b/src/backend/drivers/teambition/driver.ts @@ -138,30 +138,38 @@ export class DriverTeambition implements StorageDriver { async move( _srcDir: string, - dstDir: string, + _dstDir: string, _names: string[], srcPhysical: string, - _dstPhysical: string, + dstPhysical: string, ): Promise { const entry = await this.resolveEntry(srcPhysical) - await this.client.move(entry.id, await this.pathToId(dstDir), entry.isDir) + const dstParentId = await this.pathToId(parentOf(dstPhysical)) + await this.client.move(entry.id, dstParentId, entry.isDir) this.pathCache.clear() } async copy( _srcDir: string, - dstDir: string, + _dstDir: string, _names: string[], srcPhysical: string, - _dstPhysical: string, + dstPhysical: string, ): Promise { const entry = await this.resolveEntry(srcPhysical) - await this.client.copy(entry.id, await this.pathToId(dstDir), entry.isDir) + const dstParentId = await this.pathToId(parentOf(dstPhysical)) + await this.client.copy(entry.id, dstParentId, entry.isDir) this.pathCache.clear() } - async put(): Promise { - throw new Error("[Teambition] upload not supported in stateless environment") + async put( + _virtualPath: string, + _physicalPath: string, + _content: Uint8Array, + ): Promise { + throw new Error( + "[Teambition] upload is not supported on this runtime (list/download/rename/move/copy/remove are supported)", + ) } private async resolveEntry(physicalPath: string): Promise<{ id: string; isDir: boolean }> { @@ -177,14 +185,14 @@ export class DriverTeambition implements StorageDriver { throw new Error(`[Teambition] '${name}' not found`) } - /** 物理路径 → 目录 ID(根目录返回空串) */ + /** 物理路径 → 目录 ID(根目录返回 root_folder_id,未配置则为空串) */ private async pathToId(physicalPath: string): Promise { const clean = physicalPath.split("/").filter(Boolean).join("/") - if (!clean) return "" + if (!clean) return this.client.rootId if (this.pathCache.has(clean)) return this.pathCache.get(clean)! const parts = clean.split("/") - let currentId = "" + let currentId = this.client.rootId for (let i = 0; i < parts.length; i++) { const { folders } = await this.client.getFiles(currentId) const target = folders.find((c) => c.title === parts[i]) @@ -196,3 +204,12 @@ export class DriverTeambition implements StorageDriver { return currentId } } + +/** 物理路径的父目录("a/b/c" → "/a/b","a" → "/") */ +function parentOf(physicalPath: string): string { + const parts = String(physicalPath || "") + .split("/") + .filter(Boolean) + parts.pop() + return "/" + parts.join("/") +} diff --git a/src/backend/drivers/teambition/types.ts b/src/backend/drivers/teambition/types.ts index 37cdd8e7..8ae4be6c 100644 --- a/src/backend/drivers/teambition/types.ts +++ b/src/backend/drivers/teambition/types.ts @@ -5,6 +5,8 @@ export interface DriverTeambitionAddition { region: string // "china" | "international" cookie: string project_id: string + /** 对齐 Go driver.RootID:留空表示项目根目录 */ + root_folder_id?: string order_by?: string order_direction?: string } diff --git a/src/backend/drivers/teambition/util.ts b/src/backend/drivers/teambition/util.ts index 632fb450..b69b6d39 100644 --- a/src/backend/drivers/teambition/util.ts +++ b/src/backend/drivers/teambition/util.ts @@ -21,6 +21,7 @@ export class ClientTeambition { async init(): Promise { if (!this.addition.cookie) throw new Error("[Teambition] cookie is required") if (!this.addition.project_id) throw new Error("[Teambition] project_id is required") + if (!this.addition.region) this.addition.region = "china" // 验证 cookie 有效 const resp = await fetch(`${this.baseUrl}/api/v2/roles`, { headers: { Cookie: this.addition.cookie }, @@ -28,6 +29,11 @@ export class ClientTeambition { if (resp.status >= 400) throw new Error(`[Teambition] init failed: HTTP ${resp.status}`) } + /** 根目录 ID:对齐 Go driver.RootID,留空表示项目根目录 */ + get rootId(): string { + return (this.addition.root_folder_id || "").trim() + } + private async request(pathname: string, method: string, body?: any, query?: Record): Promise { const qs = query ? "?" + new URLSearchParams(query).toString() : "" const headers: Record = { Cookie: this.addition.cookie } @@ -37,12 +43,36 @@ export class ClientTeambition { headers["Content-Type"] = "application/json" } const resp = await fetch(`${this.baseUrl}${pathname}${qs}`, options) - if (resp.status === 204 || resp.status === 201) return {} as T - const data = await resp.json().catch(() => ({})) - if ((data as TbErrResp).name || (data as TbErrResp).message) { - throw new Error(`[Teambition] ${(data as TbErrResp).message || (data as TbErrResp).name}`) + if (resp.status === 204) return {} as T + + const text = await resp.text() + let data: any = null + if (text.trim()) { + try { + data = JSON.parse(text) + } catch { + data = null + } + } + // 成功响应里也可能带 message 字段(如部分集合对象),只有带 name+message + // 且不含业务主键时才当作错误体,避免把正常结果误判为失败。 + const err = data as TbErrResp | null + const isErrPayload = + !!err && + typeof err === "object" && + !Array.isArray(err) && + typeof err.name === "string" && + typeof err.message === "string" && + !("_id" in (err as any)) + if (!resp.ok || isErrPayload) { + const reason = isErrPayload + ? (err as TbErrResp).message || (err as TbErrResp).name + : text.trim() + ? `HTTP ${resp.status}: ${text.slice(0, 200)}` + : `HTTP ${resp.status}` + throw new Error(`[Teambition] ${method} ${pathname} failed: ${reason}`) } - return data as T + return (data ?? {}) as T } async getFiles(parentId: string): Promise<{ folders: TbCollection[]; works: TbWork[] }> { diff --git a/src/backend/server/admin.ts b/src/backend/server/admin.ts index 243dc72e..bb45e064 100644 --- a/src/backend/server/admin.ts +++ b/src/backend/server/admin.ts @@ -186,6 +186,7 @@ export const normalizeDriver = (driverName: string): string => { if (norm.startsWith("githubrelease")) return "GitHub Releases" if (norm.startsWith("cnbrelease")) return "CNB Releases" if (norm.startsWith("github")) return "GitHub API" + if (norm.includes("teambition") || norm === "tb") return "Teambition" if (norm === "local") return "Local" if (norm.includes("pikpak")) return "PikPak" if (norm.includes("seafile")) return "Seafile" @@ -556,6 +557,7 @@ adminRouter.get("/driver/names", (c) => { "ProtonDrive", "GitHub Releases", "CNB Releases", + "Teambition", ], }) }) @@ -4674,6 +4676,68 @@ const driverConfigs: Record = { default_root: "root", }, }, + // 对齐 Go drivers/teambition:Cookie 认证,无 MustProxy / PreferProxy + Teambition: { + name: "Teambition", + default_mount_path: "/teambition", + common: COMMON_FIELDS, + additional: [ + { + name: "region", + type: "select", + options: "china,international", + default: "china", + required: true, + help: "china = www.teambition.com,international = us.teambition.com", + }, + { + name: "cookie", + type: "text", + default: "", + required: true, + help: "浏览器登录 Teambition 后从开发者工具复制完整 Cookie 请求头", + }, + { + name: "project_id", + type: "string", + default: "", + required: true, + help: "项目 ID:打开项目页面,地址栏 /project//... 中的那一段", + }, + { + name: "root_folder_id", + type: "string", + default: "", + required: false, + help: "可选根目录 ID(对齐 Go RootID);留空表示项目根目录", + }, + { + name: "order_by", + type: "select", + options: "fileName,fileSize,updated,created", + default: "fileName", + required: false, + }, + { + name: "order_direction", + type: "select", + options: "Asc,Desc", + default: "Asc", + required: false, + }, + ], + config: { + name: "Teambition", + local_sort: false, + only_local: false, + only_proxy: false, + no_cache: false, + // Go 版支持上传(S3 / 分片);TS 无状态实现 put 直接报错,先隐藏上传入口 + no_upload: true, + need_ms: false, + default_root: "", + }, + }, } adminRouter.get("/driver/list", (c) => { diff --git a/src/backend/server/driver_list.test.ts b/src/backend/server/driver_list.test.ts index 3050e7cd..0163abb8 100644 --- a/src/backend/server/driver_list.test.ts +++ b/src/backend/server/driver_list.test.ts @@ -228,3 +228,46 @@ test("存储列表中的驱动配置可被 driver/info 复用(前端编辑页 ) } }) + +test("Teambition 出现在驱动列表中,且字段与 Go 版对齐", async () => { + const env = await setupEnv() + const app = makeApp() + + const list = await app.request( + "/api/admin/driver/list", + { method: "GET", headers: authHeaders }, + env, + ) + const listBody: any = await list.json() + const tb = listBody.data["Teambition"] + assert.ok(tb, "Teambition 必须出现在驱动列表中") + assert.equal(tb.name, "Teambition") + assert.equal(tb.default_mount_path, "/teambition") + assert.equal(tb.config.only_proxy, false) + assert.equal(tb.config.no_upload, true) + + const fields = tb.additional.map((f: any) => f.name) + for (const field of [ + "region", + "cookie", + "project_id", + "root_folder_id", + "order_by", + "order_direction", + ]) { + assert.ok(fields.includes(field), `缺少字段 ${field}`) + } + + const region = tb.additional.find((f: any) => f.name === "region") + assert.equal(region.required, true) + assert.equal(region.options, "china,international") + assert.equal(region.default, "china") + + const names = await app.request( + "/api/admin/driver/names", + { method: "GET", headers: authHeaders }, + env, + ) + const namesBody: any = await names.json() + assert.ok(namesBody.data.includes("Teambition")) +})