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
45 changes: 45 additions & 0 deletions packages/filesystem/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AuthVerify } from "./auth";
import { LocalStorageDAO } from "@App/app/repo/localStorage";

describe("AuthVerify", () => {
const localStorageDAO = new LocalStorageDAO();
const key = "netdisk:token:onedrive";
let originalFetch: typeof fetch;

beforeEach(async () => {
vi.clearAllMocks();
await chrome.storage.local.clear();
originalFetch = globalThis.fetch;
});

afterEach(() => {
vi.stubGlobal("fetch", originalFetch);
});
Comment on lines +10 to +18
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里用 vi.stubGlobal("fetch", ...) 覆盖了全局 fetch,但没有在用例结束后恢复,可能导致其它测试用例受到污染(同仓库里如 packages/filesystem/s3/client.test.ts 会在 afterEach 里 vi.unstubAllGlobals)。建议在本 describe 增加 afterEach 调用 vi.unstubAllGlobals()(或保存原 fetch 并恢复),确保测试隔离。

Copilot uses AI. Check for mistakes.

it("expired token refresh network failure should reject, not fallback old token", async () => {
await localStorageDAO.saveValue(key, {
accessToken: "old-access",
refreshToken: "old-refresh",
createtime: Date.now() - 3600000 - 1000,
});

vi.stubGlobal("fetch", vi.fn().mockRejectedValueOnce(new Error("refresh network failed")));

await expect(AuthVerify("onedrive")).rejects.toThrow("refresh network failed");
});

it("non-expired token should return cached token without refresh", async () => {
await localStorageDAO.saveValue(key, {
accessToken: "cached-access",
refreshToken: "cached-refresh",
createtime: Date.now(),
});

const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);

await expect(AuthVerify("onedrive")).resolves.toBe("cached-access");
expect(fetchMock).not.toHaveBeenCalled();
});
});
10 changes: 6 additions & 4 deletions packages/filesystem/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ export async function AuthVerify(netDiskType: NetDiskType, invalid?: boolean) {
await localStorageDAO.saveValue(key, token);
}
// token过期或者失效
if (Date.now() >= token.createtime + 3600000 || invalid) {
const expired = Date.now() >= token.createtime + 3600000;
if (expired || invalid) {
// 大于一小时刷新token
try {
const resp = await RefreshToken(netDiskType, token.refreshToken);
Expand All @@ -119,9 +120,10 @@ export async function AuthVerify(netDiskType: NetDiskType, invalid?: boolean) {
};
// 更新token
await localStorageDAO.saveValue(key, token);
} catch (_) {
// 报错返回原token
return token.accessToken;
} catch (e) {
// 已过期或已被服务端判定失效的 token 不能继续回退使用
console.warn(e);
throw e;
}
} else {
return token.accessToken;
Expand Down
17 changes: 17 additions & 0 deletions packages/filesystem/dropbox/dropbox.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import DropboxFileSystem from "./dropbox";

describe("DropboxFileSystem", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("delete should be idempotent on path not found", async () => {
const fs = new DropboxFileSystem("/", "token");
vi.spyOn(fs, "request").mockRejectedValue(
new Error('Dropbox API Error: 409 - {"error_summary":"path_lookup/not_found/..."}')
);

await expect(fs.delete("missing.txt")).resolves.toBeUndefined();
});
});
43 changes: 32 additions & 11 deletions packages/filesystem/dropbox/dropbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,14 +78,28 @@ export default class DropboxFileSystem implements FileSystem {
request(url: string, config?: RequestInit, nothen?: boolean) {
config = config || {};
const headers = <Headers>config.headers || new Headers();
headers.append(`Authorization`, `Bearer ${this.accessToken}`);
headers.set(`Authorization`, `Bearer ${this.accessToken}`);
config.headers = headers;
const ret = fetch(url, config);
const doFetch = () => fetch(url, config);
const retryWithFreshToken = async () => {
const token = await AuthVerify("dropbox", true);
this.accessToken = token;
headers.set(`Authorization`, `Bearer ${this.accessToken}`);
return doFetch();
};
if (nothen) {
return <Promise<Response>>ret;
return doFetch().then(async (resp) => {
if (resp.status === 401) {
return retryWithFreshToken();
}
return resp;
});
}
return ret
return doFetch()
.then(async (response) => {
if (response.status === 401) {
response = await retryWithFreshToken();
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Dropbox API Error: ${response.status} - ${errorText}`);
Expand Down Expand Up @@ -126,13 +140,20 @@ export default class DropboxFileSystem implements FileSystem {
const myHeaders = new Headers();
myHeaders.append("Content-Type", "application/json");

await this.request("https://api.dropboxapi.com/2/files/delete_v2", {
method: "POST",
headers: myHeaders,
body: JSON.stringify({
path: fullPath,
}),
});
try {
await this.request("https://api.dropboxapi.com/2/files/delete_v2", {
method: "POST",
headers: myHeaders,
body: JSON.stringify({
path: fullPath,
}),
});
} catch (e: any) {
if (e.message?.includes("path_lookup/not_found") || e.message?.includes("path/not_found")) {
return;
}
throw e;
}

// 清除相关缓存
this.clearRelatedCache(fullPath);
Expand Down
28 changes: 28 additions & 0 deletions packages/filesystem/googledrive/googledrive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import GoogleDriveFileSystem from "./googledrive";

describe("GoogleDriveFileSystem", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("delete should be idempotent when file id is missing", async () => {
const fs = new GoogleDriveFileSystem("/", "token");
vi.spyOn(fs, "getFileId").mockResolvedValue(null);
const requestSpy = vi.spyOn(fs, "request");

await expect(fs.delete("missing.txt")).resolves.toBeUndefined();
expect(requestSpy).not.toHaveBeenCalled();
});

it("delete should be idempotent on 404 response", async () => {
const fs = new GoogleDriveFileSystem("/", "token");
vi.spyOn(fs, "getFileId").mockResolvedValue("file-1");
vi.spyOn(fs, "request").mockResolvedValue({
status: 404,
text: vi.fn().mockResolvedValue("not found"),
} as unknown as Response);

await expect(fs.delete("missing.txt")).resolves.toBeUndefined();
});
});
46 changes: 35 additions & 11 deletions packages/filesystem/googledrive/googledrive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,23 +110,44 @@ export default class GoogleDriveFileSystem implements FileSystem {
request(url: string, config?: RequestInit, nothen?: boolean) {
config = config || {};
const headers = <Headers>config.headers || new Headers();
headers.append(`Authorization`, `Bearer ${this.accessToken}`);
headers.set(`Authorization`, `Bearer ${this.accessToken}`);
config.headers = headers;
const ret = fetch(url, config);
const doFetch = () => fetch(url, config);
const retryWithFreshToken = async () => {
const token = await AuthVerify("googledrive", true);
this.accessToken = token;
headers.set(`Authorization`, `Bearer ${this.accessToken}`);
return doFetch();
};
if (nothen) {
return <Promise<Response>>ret;
return doFetch().then(async (resp) => {
if (resp.status === 401) {
return retryWithFreshToken();
}
return resp;
});
}
return ret
.then((data) => data.json())
return doFetch()
.then(async (resp) => {
if (resp.status === 401) {
resp = await retryWithFreshToken();
}
if (!resp.ok) {
throw new Error(await resp.text());
}
return resp.json();
})
.then(async (data) => {
if (data.error) {
if (data.error.code === 401) {
// Token可能过期,尝试刷新
const token = await AuthVerify("googledrive", true);
this.accessToken = token;
headers.set(`Authorization`, `Bearer ${this.accessToken}`);
return fetch(url, config)
.then((retryData) => retryData.json())
return retryWithFreshToken()
.then(async (retryResp) => {
if (!retryResp.ok) {
throw new Error(await retryResp.text());
}
return retryResp.json();
})
.then((retryData) => {
if (retryData.error) {
throw new Error(JSON.stringify(retryData));
Expand All @@ -145,7 +166,7 @@ export default class GoogleDriveFileSystem implements FileSystem {
// 首先,找到要删除的文件或文件夹
const fileId = await this.getFileId(fullPath);
if (!fileId) {
throw new Error(`File or directory not found: ${fullPath}`);
return;
}

// 删除文件或文件夹
Expand All @@ -156,6 +177,9 @@ export default class GoogleDriveFileSystem implements FileSystem {
},
true
).then(async (resp) => {
if (resp.status === 404) {
return;
}
if (resp.status !== 204 && resp.status !== 200) {
throw new Error(await resp.text());
}
Expand Down
96 changes: 96 additions & 0 deletions packages/filesystem/onedrive/onedrive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import OneDriveFileSystem from "./onedrive";
import { LocalStorageDAO } from "@App/app/repo/localStorage";

function createMockResponse(options: { ok?: boolean; status?: number; text?: string; json?: any }): Response {
const { ok = true, status = 200, text = "", json = {} } = options;
return {
ok,
status,
text: vi.fn().mockResolvedValue(text),
json: vi.fn().mockResolvedValue(json),
headers: new Headers(),
} as unknown as Response;
}

describe("OneDriveFileSystem", () => {
const localStorageDAO = new LocalStorageDAO();
let originalFetch: typeof fetch;

beforeEach(async () => {
vi.clearAllMocks();
await chrome.storage.local.clear();
originalFetch = globalThis.fetch;
});

afterEach(() => {
vi.stubGlobal("fetch", originalFetch);
});
Comment on lines +20 to +28
Copy link

Copilot AI Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

该文件通过 vi.stubGlobal("fetch", fetchMock) 注入全局 fetch,但没有在用例结束后 unstub,可能污染同一测试进程中的其他 suite。建议补充 afterEach(() => vi.unstubAllGlobals())(或在 beforeEach 里先 unstub)来保证测试隔离。

Copilot uses AI. Check for mistakes.

it("request should return retry result after token refresh", async () => {
await localStorageDAO.saveValue("netdisk:token:onedrive", {
accessToken: "expired-token",
refreshToken: "refresh-token",
createtime: Date.now(),
});

const fs = new OneDriveFileSystem("/", "expired-token");
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
json: {
error: {
code: "InvalidAuthenticationToken",
},
},
})
)
.mockResolvedValueOnce({
json: vi.fn().mockResolvedValue({
code: 0,
data: {
token: {
access_token: "fresh-token",
refresh_token: "fresh-refresh-token",
},
},
}),
} as unknown as Response)
.mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
json: {
value: [
{
name: "ok.txt",
size: 1,
eTag: "tag",
createdDateTime: new Date().toISOString(),
lastModifiedDateTime: new Date().toISOString(),
},
],
},
})
);
vi.stubGlobal("fetch", fetchMock);

const data = await fs.request("https://graph.microsoft.com/v1.0/me/drive/special/approot/children");

expect(data.value).toHaveLength(1);
expect(fetchMock).toHaveBeenCalledTimes(3);
});

it("delete should be idempotent on 404", async () => {
const fs = new OneDriveFileSystem("/", "token");
vi.spyOn(fs, "request").mockResolvedValue({
status: 404,
text: vi.fn().mockResolvedValue("not found"),
} as unknown as Response);

await expect(fs.delete("missing.txt")).resolves.toBeUndefined();
});
});
Loading
Loading