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
18 changes: 8 additions & 10 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
## 概述 Descriptions
## Checklist / 检查清单

<!-- 如果有关联的 issue,请在下面记载 -->
<!-- Describe related issue(s) if any. -->
<!-- close #xxx -->
- [ ] Fixes mentioned issues / 修复已提及的问题
- [ ] Code reviewed by human / 代码通过人工检查
- [ ] Changes tested / 已完成测试

## 变更内容 Changes
## Description / 描述

<!-- - 这个 PR 做了什么? -->
<!-- - What does this PR do? -->
<!-- Description / PR 描述 -->

### 截图 Screenshots
## Screenshots / 截图

<!-- 如果可以展示页面,请务必附上截图 -->
<!-- If it can be illustrated, please provide a screenshot. -->
<!-- Screenshots / 截图-->
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ CLAUDE.md

test-results
playwright-report

superpowers
38 changes: 35 additions & 3 deletions packages/filesystem/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import ZipFileSystem from "./zip/zip";
import S3FileSystem from "./s3/s3";
import { t } from "@App/locales/locales";
import LimiterFileSystem from "./limiter";
import type { WebDAVClientOptions, OAuthToken } from "webdav";

export type FileSystemType = "zip" | "webdav" | "baidu-netdsik" | "onedrive" | "googledrive" | "dropbox" | "s3";

Expand All @@ -16,18 +17,47 @@ export type FileSystemParams = {
title: string;
type?: "select" | "authorize" | "password";
options?: string[];
visibilityFor?: string[];
minWidth?: string;
};
};

export default class FileSystemFactory {
static create(type: FileSystemType, params: any): Promise<FileSystem> {
let fs: FileSystem;
let options;
switch (type) {
case "zip":
fs = new ZipFileSystem(params);
break;
case "webdav":
fs = new WebDAVFileSystem(params.authType, params.url, params.username, params.password);
/*
Auto = "auto",
Digest = "digest", // 需要避免密码直接传输
None = "none", // 公开资源 / 自定义认证
Password = "password", // 普通 WebDAV 服务,需要确保 HTTPS / Nextcloud 生产环境
Token = "token" // OAuth2 / 现代云服务 / Nextcloud 生产环境
*/
if (params.authType === "none") {
options = {
authType: params.authType,
} satisfies WebDAVClientOptions;
} else if (params.authType === "token") {
options = {
authType: params.authType,
token: {
token_type: "Bearer",
access_token: params.accessToken,
} satisfies OAuthToken,
} satisfies WebDAVClientOptions;
} else {
options = {
authType: params.authType || "auto", // UI 问题,有undefined机会。undefined等价于 password, 但此处用 webdav 本身的 auto 侦测算了
username: params.username,
password: params.password,
} satisfies WebDAVClientOptions;
}
fs = WebDAVFileSystem.fromCredentials(params.url, options);
break;
case "baidu-netdsik":
fs = new BaiduFileSystem();
Expand Down Expand Up @@ -64,10 +94,12 @@ export default class FileSystemFactory {
title: t("auth_type"),
type: "select",
options: ["password", "digest", "none", "token"],
minWidth: "140px",
},
url: { title: t("url") },
username: { title: t("username") },
password: { title: t("password"), type: "password" },
username: { title: t("username"), visibilityFor: ["password", "digest"] },
password: { title: t("password"), type: "password", visibilityFor: ["password", "digest"] },
accessToken: { title: t("access_token_bearer"), visibilityFor: ["token"] },
},
"baidu-netdsik": {},
onedrive: {},
Expand Down
231 changes: 231 additions & 0 deletions packages/filesystem/webdav/webdav.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { WebDAVClient } from "webdav";
import { getPatcher } from "webdav";
import WebDAVFileSystem from "./webdav";
import { WarpTokenError } from "../error";

/** 创建 mock WebDAVClient */
function createMockClient(overrides?: Partial<WebDAVClient>): WebDAVClient {
return {
getQuota: vi.fn().mockResolvedValue({}),
getDirectoryContents: vi.fn().mockResolvedValue([]),
getFileContents: vi.fn().mockResolvedValue("content"),
putFileContents: vi.fn().mockResolvedValue(true),
createDirectory: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn().mockResolvedValue(undefined),
...overrides,
} as unknown as WebDAVClient;
}

/** 创建可测试的 WebDAVFileSystem 实例(替换 client 为 mock) */
function createTestFS(mockClient: WebDAVClient, url = "https://dav.example.com"): WebDAVFileSystem {
const fs = WebDAVFileSystem.fromCredentials(url, {});
fs.client = mockClient;
return fs;
}

describe("WebDAVFileSystem", () => {
let mockClient: WebDAVClient;

beforeEach(() => {
vi.clearAllMocks();
mockClient = createMockClient();
});

describe("initWebDAVPatch", () => {
it("应当通过 getPatcher 注册 fetch patch,设置 credentials 为 omit", () => {
// fromCredentials 内部调用 initWebDAVPatch,验证 patcher 已注册 fetch
WebDAVFileSystem.fromCredentials("https://dav.example.com", {});

const patcher = getPatcher();
// 验证 fetch 已被 patch(patcher 内部有 fetch 注册)
expect(patcher.isPatched("fetch")).toBe(true);
});
});

describe("fromCredentials", () => {
it("应当创建 WebDAVFileSystem 实例并设置 url 和 basePath", () => {
const fs = WebDAVFileSystem.fromCredentials("https://dav.example.com", {
authType: "password" as any,
username: "user",
password: "pass",
});

expect(fs).toBeInstanceOf(WebDAVFileSystem);
expect(fs.url).toBe("https://dav.example.com");
expect(fs.basePath).toBe("/");
});
});

describe("fromSameClient", () => {
it("应当复用已有 client 并设置新 basePath", () => {
const fs = createTestFS(mockClient);
const subFs = WebDAVFileSystem.fromSameClient(fs, "/subdir");

expect(subFs).toBeInstanceOf(WebDAVFileSystem);
expect(subFs.url).toBe("https://dav.example.com");
expect(subFs.basePath).toBe("/subdir");
expect(subFs.client).toBe(mockClient);
});
});

describe("verify", () => {
it("应当成功验证", async () => {
const fs = createTestFS(mockClient);

await expect(fs.verify()).resolves.toBeUndefined();
expect(mockClient.getQuota).toHaveBeenCalled();
});

it("应当在 401 时抛出 WarpTokenError", async () => {
(mockClient.getQuota as ReturnType<typeof vi.fn>).mockRejectedValue({
response: { status: 401 },
message: "Unauthorized",
});
const fs = createTestFS(mockClient);

await expect(fs.verify()).rejects.toBeInstanceOf(WarpTokenError);
});

it("应当在其他错误时抛出包含原始信息的 Error", async () => {
(mockClient.getQuota as ReturnType<typeof vi.fn>).mockRejectedValue({
message: "Network error",
});
const fs = createTestFS(mockClient);

await expect(fs.verify()).rejects.toThrow("WebDAV verify failed: Network error");
});
});

describe("openDir", () => {
it("应当返回新实例并拼接路径", async () => {
const fs = createTestFS(mockClient);
const subFs = (await fs.openDir("docs")) as WebDAVFileSystem;

expect(subFs).toBeInstanceOf(WebDAVFileSystem);
expect(subFs.basePath).toBe("/docs");
expect(subFs.client).toBe(mockClient);
});

it("应当支持嵌套 openDir", async () => {
const fs = createTestFS(mockClient);
const sub1 = (await fs.openDir("a")) as WebDAVFileSystem;
const sub2 = (await sub1.openDir("b")) as WebDAVFileSystem;

expect(sub2.basePath).toBe("/a/b");
});
});

describe("createDir", () => {
it("应当调用 createDirectory", async () => {
const fs = createTestFS(mockClient);

await fs.createDir("new-folder");

expect(mockClient.createDirectory).toHaveBeenCalledWith("/new-folder");
});

it("应当在 405 错误时静默成功(目录已存在)", async () => {
(mockClient.createDirectory as ReturnType<typeof vi.fn>).mockRejectedValue({
response: { status: 405 },
message: "405 Method Not Allowed",
});
const fs = createTestFS(mockClient);

await expect(fs.createDir("existing")).resolves.toBeUndefined();
});

it("应当在 message 包含 405 时也静默成功", async () => {
(mockClient.createDirectory as ReturnType<typeof vi.fn>).mockRejectedValue({
message: "Request failed with status code 405",
});
const fs = createTestFS(mockClient);

await expect(fs.createDir("existing")).resolves.toBeUndefined();
});

it("应当在其他错误时抛出异常", async () => {
const err = new Error("Forbidden");
(mockClient.createDirectory as ReturnType<typeof vi.fn>).mockRejectedValue(err);
const fs = createTestFS(mockClient);

await expect(fs.createDir("denied")).rejects.toThrow("Forbidden");
});
});

describe("delete", () => {
it("应当调用 deleteFile", async () => {
const fs = createTestFS(mockClient);

await fs.delete("test.txt");

expect(mockClient.deleteFile).toHaveBeenCalledWith("/test.txt");
});
});

describe("list", () => {
it("应当列出文件并过滤目录", async () => {
(mockClient.getDirectoryContents as ReturnType<typeof vi.fn>).mockResolvedValue([
{
type: "file",
basename: "test.txt",
lastmod: "2024-01-01T00:00:00Z",
etag: '"abc"',
size: 1024,
},
{
type: "directory",
basename: "subdir",
lastmod: "2024-01-01T00:00:00Z",
etag: "",
size: 0,
},
]);
const fs = createTestFS(mockClient);

const files = await fs.list();

expect(files).toHaveLength(1);
expect(files[0]).toMatchObject({
name: "test.txt",
path: "/",
digest: '"abc"',
size: 1024,
});
});

it("应当在 404 时返回空数组", async () => {
(mockClient.getDirectoryContents as ReturnType<typeof vi.fn>).mockRejectedValue({
response: { status: 404 },
});
const fs = createTestFS(mockClient);

const files = await fs.list();
expect(files).toHaveLength(0);
});

it("应当在其他错误时抛出异常", async () => {
const err = new Error("Server Error");
(err as any).response = { status: 500 };
(mockClient.getDirectoryContents as ReturnType<typeof vi.fn>).mockRejectedValue(err);
const fs = createTestFS(mockClient);

await expect(fs.list()).rejects.toThrow("Server Error");
});
});

describe("getDirUrl", () => {
it("应当返回 url + basePath", async () => {
const fs = createTestFS(mockClient);
const subFs = (await fs.openDir("docs")) as WebDAVFileSystem;

expect(await subFs.getDirUrl()).toBe("https://dav.example.com/docs");
});

it("根路径应返回 url + /", async () => {
const fs = createTestFS(mockClient);

expect(await fs.getDirUrl()).toBe("https://dav.example.com/");
});
});
});
Loading
Loading