Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions src/backend/drivers/teambition/driver.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, any[]>
works?: Record<string, any[]>
}

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/,
)
})
39 changes: 28 additions & 11 deletions src/backend/drivers/teambition/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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<void> {
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<void> {
throw new Error("[Teambition] upload not supported in stateless environment")
async put(
_virtualPath: string,
_physicalPath: string,
_content: Uint8Array,
): Promise<void> {
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 }> {
Expand All @@ -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<string> {
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])
Expand All @@ -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("/")
}
2 changes: 2 additions & 0 deletions src/backend/drivers/teambition/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
40 changes: 35 additions & 5 deletions src/backend/drivers/teambition/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,19 @@ export class ClientTeambition {
async init(): Promise<void> {
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 },
})
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<T = any>(pathname: string, method: string, body?: any, query?: Record<string, string>): Promise<T> {
const qs = query ? "?" + new URLSearchParams(query).toString() : ""
const headers: Record<string, string> = { Cookie: this.addition.cookie }
Expand All @@ -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[] }> {
Expand Down
Loading
Loading