Skip to content
Merged
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
161 changes: 161 additions & 0 deletions src/backend/drivers/cnb_releases/driver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import test from "node:test"
import assert from "node:assert/strict"
import { DriverCnbReleases } from "./driver"

/**
* CNB Releases 驱动回归测试:API 按 release/asset ID 操作,而 TS 链路传路径名,
* 必须先解析出 ID(旧实现直接把路径段当 ID,进入 release 目录会 404)。
*/

const RELEASE = {
id: "rel-1",
name: "v1.0.0",
tag_name: "v1.0.0",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
assets: [
{
id: "asset-1",
name: "file.zip",
size: 5,
path: "/org/repo/-/releases/download/file.zip",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-02T00:00:00Z",
},
],
}

const ADDITION = { repo: "org/repo", token: "tkn" }

function installFetch(): { calls: string[]; restore: () => void } {
const original = globalThis.fetch
const calls: string[] = []
globalThis.fetch = (async (input: any, init?: any) => {
const url = String(input)
calls.push(`${init?.method || "GET"} ${url}`)
if (url.includes("/org/repo/-/releases") && url.includes("/assets/")) {
return new Response(null, { status: 204 })
}
if (url.endsWith("/org/repo/-/releases")) {
return new Response(JSON.stringify([RELEASE]), {
status: 200,
headers: { "content-type": "application/json" },
})
}
if (url.includes("/org/repo/-/releases/")) {
return new Response(JSON.stringify(RELEASE), {
status: 200,
headers: { "content-type": "application/json" },
})
}
return new Response("", { status: 404 })
}) as typeof fetch
return {
calls,
restore: () => {
globalThis.fetch = original
},
}
}

test("CNB Releases 驱动 init 校验 repo/token", async () => {
await assert.rejects(
() => new DriverCnbReleases({ repo: "", token: "" }).init(),
/repo is required/,
)
await assert.rejects(
() => new DriverCnbReleases({ repo: "org/repo", token: "" }).init(),
/token is required/,
)
})

test("根目录列出 releases(目录 sign 为 release ID)", async () => {
const { restore } = installFetch()
try {
const driver = new DriverCnbReleases(ADDITION)
const items = await driver.list("", "/")
assert.equal(items.length, 1)
assert.equal(items[0].name, "v1.0.0")
assert.equal(items[0].is_dir, true)
assert.equal(items[0].sign, "rel-1")
assert.equal(items[0].size, 5)
} finally {
restore()
}
})

test("按 release 名称进入目录并列出资产", async () => {
const { restore } = installFetch()
try {
const driver = new DriverCnbReleases(ADDITION)
const items = await driver.list("/v1.0.0", "/v1.0.0")
assert.equal(items.length, 1)
assert.equal(items[0].name, "file.zip")
assert.equal(items[0].is_dir, false)
assert.equal(
items[0].raw_url,
"https://cnb.cool/org/repo/-/releases/download/file.zip",
)
} finally {
restore()
}
})

test("get:release 目录与资产文件都能按名称解析", async () => {
const { restore } = installFetch()
try {
const driver = new DriverCnbReleases(ADDITION)

const dir = await driver.get("/v1.0.0", "/v1.0.0")
assert.equal(dir.is_dir, true)
assert.equal(dir.name, "v1.0.0")

const file = await driver.get("/v1.0.0/file.zip", "/v1.0.0/file.zip")
assert.equal(file.is_dir, false)
assert.equal(file.name, "file.zip")
assert.equal(
file.raw_url,
"https://cnb.cool/org/repo/-/releases/download/file.zip",
)
} finally {
restore()
}
})

test("remove:按名称解析出 release/asset ID 后再调用删除接口", async () => {
const { calls, restore } = installFetch()
try {
const driver = new DriverCnbReleases(ADDITION)
await driver.remove("", "/v1.0.0/file.zip", ["file.zip"])
assert.ok(
calls.some(
(c) =>
c.startsWith("DELETE") &&
c.includes("/org/repo/-/releases/rel-1/assets/asset-1"),
),
`未按 ID 调用删除接口: ${JSON.stringify(calls)}`,
)
} finally {
restore()
}
})

test("use_tag_name 时目录名使用 tag 且不允许重命名", async () => {
const { restore } = installFetch()
try {
const driver = new DriverCnbReleases({ ...ADDITION, use_tag_name: true })
const items = await driver.list("", "/")
assert.equal(items[0].name, "v1.0.0")
await assert.rejects(
() => driver.rename("", "/v1.0.0", "new"),
/only release name can be renamed/,
)
} finally {
restore()
}
})

test("无状态环境下 put 直接报错", async () => {
const driver = new DriverCnbReleases(ADDITION)
await assert.rejects(() => driver.put(), /asset upload not supported/)
})
57 changes: 38 additions & 19 deletions src/backend/drivers/cnb_releases/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export class DriverCnbReleases implements StorageDriver {
raw_url: "",
}))
}
// release 目录:assets
const releaseId = clean.split("/")[0]
const release = await this.client.getRelease(releaseId)
// release 目录:先按名称解析出 ID
const release = await this.client.findRelease(clean.split("/")[0])
if (!release) return []
return release.assets.map((a) => ({
name: a.name,
size: a.size,
Expand All @@ -55,29 +55,33 @@ export class DriverCnbReleases implements StorageDriver {

// release 目录
if (parts.length === 1) {
try {
const release = await this.client.getRelease(parts[0])
const release = await this.client.findRelease(parts[0])
if (release) {
return {
name: this.client.releaseName(release),
size: release.assets.reduce((s, a) => s + a.size, 0),
is_dir: true,
modified: release.updated_at || release.created_at || new Date().toISOString(),
modified:
release.updated_at ||
release.created_at ||
new Date().toISOString(),
sign: release.id,
type: 1,
raw_url: "",
}
} catch {}
}
}
// asset 文件
if (parts.length === 2) {
const release = await this.client.getRelease(parts[0])
const asset = release.assets.find((a) => a.name === parts[1])
if (asset) {
const release = await this.client.findRelease(parts[0])
const asset = release ? this.client.findAsset(release, parts[1]) : null
if (release && asset) {
return {
name: asset.name,
size: asset.size,
is_dir: false,
modified: asset.updated_at || asset.created_at || new Date().toISOString(),
modified:
asset.updated_at || asset.created_at || new Date().toISOString(),
sign: asset.id,
type: calcFileType(asset.name, false),
raw_url: this.client.assetDownloadUrl(asset),
Expand All @@ -98,7 +102,10 @@ export class DriverCnbReleases implements StorageDriver {
async mkdir(_virtualPath: string, physicalPath: string): Promise<void> {
const parts = physicalPath.split("/").filter(Boolean)
if (parts.length === 1) {
await this.client.createRelease(parts[0], this.addition.default_branch || "main")
await this.client.createRelease(
parts[0],
this.addition.default_branch || "main",
)
return
}
throw new Error("[CNB Releases] only supports creating releases at root")
Expand All @@ -111,8 +118,11 @@ export class DriverCnbReleases implements StorageDriver {
): Promise<void> {
const parts = physicalPath.split("/").filter(Boolean)
if (parts.length === 1 && !this.addition.use_tag_name) {
await this.client.renameRelease(parts[0], newName)
return
const release = await this.client.findRelease(parts[0])
if (release) {
await this.client.renameRelease(release.id, newName)
return
}
}
throw new Error("[CNB Releases] only release name can be renamed")
}
Expand All @@ -124,12 +134,19 @@ export class DriverCnbReleases implements StorageDriver {
): Promise<void> {
const parts = physicalPath.split("/").filter(Boolean)
if (parts.length === 1) {
await this.client.deleteRelease(parts[0])
return
const release = await this.client.findRelease(parts[0])
if (release) {
await this.client.deleteRelease(release.id)
return
}
}
if (parts.length === 2) {
await this.client.deleteAsset(parts[0], parts[1])
return
const release = await this.client.findRelease(parts[0])
const asset = release ? this.client.findAsset(release, parts[1]) : null
if (release && asset) {
await this.client.deleteAsset(release.id, asset.id)
return
}
}
throw new Error("[CNB Releases] invalid path")
}
Expand All @@ -143,6 +160,8 @@ export class DriverCnbReleases implements StorageDriver {
}

async put(): Promise<void> {
throw new Error("[CNB Releases] asset upload not supported in stateless environment")
throw new Error(
"[CNB Releases] asset upload not supported in stateless environment",
)
}
}
43 changes: 35 additions & 8 deletions src/backend/drivers/cnb_releases/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,10 @@ export class ClientCnbReleases {
}
}
const resp = await fetch(`${API_BASE}${path}`, options)
if (
resp.status !== 200 &&
resp.status !== 201 &&
resp.status !== 204
) {
throw new Error(`[CNB Releases] ${resp.status} ${await resp.text().catch(() => "")}`)
if (resp.status !== 200 && resp.status !== 201 && resp.status !== 204) {
throw new Error(
`[CNB Releases] ${resp.status} ${await resp.text().catch(() => "")}`,
)
}
if (resp.status === 204) return {} as T
return (await resp.json().catch(() => ({}))) as T
Expand All @@ -61,6 +59,27 @@ export class ClientCnbReleases {
return this.request<CNBRelease>("GET", `/${this.repo}/-/releases/${id}`)
}

/** 路径里是 release 名称(TS 链路不传 Go 对象 ID),需先解析出 ID 再调 API */
async findRelease(idOrName: string): Promise<CNBRelease | null> {
const releases = await this.listReleases().catch(() => [] as CNBRelease[])
if (!Array.isArray(releases)) return null
return (
releases.find((r) => r.id === idOrName) ||
releases.find((r) => this.releaseName(r) === idOrName) ||
releases.find((r) => r.tag_name === idOrName) ||
null
)
}

/** 兼容按资产名称或 ID 查找 */
findAsset(release: CNBRelease, idOrName: string): CNBAsset | null {
return (
release.assets.find((a) => a.id === idOrName) ||
release.assets.find((a) => a.name === idOrName) ||
null
)
}

releaseName(r: CNBRelease): string {
return this.useTagName ? r.tag_name : r.name
}
Expand All @@ -74,15 +93,23 @@ export class ClientCnbReleases {
}

renameRelease(id: string, newName: string): Promise<void> {
return this.request("PATCH", `/${this.repo}/-/releases/${id}`, { name: newName }, true)
return this.request(
"PATCH",
`/${this.repo}/-/releases/${id}`,
{ name: newName },
true,
)
}

deleteRelease(id: string): Promise<void> {
return this.request("DELETE", `/${this.repo}/-/releases/${id}`)
}

deleteAsset(releaseId: string, assetId: string): Promise<void> {
return this.request("DELETE", `/${this.repo}/-/releases/${releaseId}/assets/${assetId}`)
return this.request(
"DELETE",
`/${this.repo}/-/releases/${releaseId}/assets/${assetId}`,
)
}

assetDownloadUrl(asset: CNBAsset): string {
Expand Down
Loading