Skip to content

Commit fd30d42

Browse files
cyfung1031CodFrm
andauthored
πŸ› θ§„θŒƒεŒ– Google Drive ε’Œ OneDrive ηš„ζ–‡δ»Άη³»η»Ÿθ―·ζ±‚ι”™θ―― (#1406)
* fix(sync): θ§„θŒƒεŒ– Google Drive ε’Œ OneDrive ηš„ζ–‡δ»Άη³»η»Ÿθ―·ζ±‚ι”™θ―― * δ½œδΈΊη‹¬η«‹PRοΌŒε…ˆι€€ε›ž PR 1405 ηš„ε•ε…ƒζ΅‹θ―• * πŸ› Google Drive stale path cache 在 404 εŽζΈ…η†εΉΆι‡θ―• (#1407) --------- Co-authored-by: ηŽ‹δΈ€δΉ‹ <yz@ggnb.top>
1 parent 6ee6db0 commit fd30d42

6 files changed

Lines changed: 652 additions & 14 deletions

File tree

β€Žpackages/filesystem/error.tsβ€Ž

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,68 @@ export class WarpNetworkError {
2121
export function isNetworkError(error: any): error is WarpNetworkError {
2222
return error instanceof WarpNetworkError;
2323
}
24+
25+
export type FileSystemProvider = "googledrive" | "onedrive" | "dropbox" | "baidu" | "webdav" | "s3" | "zip";
26+
27+
export type FileSystemErrorOptions = {
28+
provider: FileSystemProvider;
29+
message: string;
30+
status?: number;
31+
code?: string;
32+
retryable?: boolean;
33+
conflict?: boolean;
34+
auth?: boolean;
35+
notFound?: boolean;
36+
rateLimit?: boolean;
37+
raw?: unknown;
38+
};
39+
40+
export class FileSystemError extends Error {
41+
provider: FileSystemProvider;
42+
43+
status?: number;
44+
45+
code?: string;
46+
47+
retryable: boolean;
48+
49+
conflict: boolean;
50+
51+
auth: boolean;
52+
53+
notFound: boolean;
54+
55+
rateLimit: boolean;
56+
57+
raw?: unknown;
58+
59+
constructor(options: FileSystemErrorOptions) {
60+
super(options.message);
61+
this.name = "FileSystemError";
62+
this.provider = options.provider;
63+
this.status = options.status;
64+
this.code = options.code;
65+
this.retryable = options.retryable ?? false;
66+
this.conflict = options.conflict ?? false;
67+
this.auth = options.auth ?? false;
68+
this.notFound = options.notFound ?? false;
69+
this.rateLimit = options.rateLimit ?? false;
70+
this.raw = options.raw;
71+
}
72+
}
73+
74+
export function isNotFoundError(error: unknown): error is FileSystemError {
75+
return error instanceof FileSystemError && error.notFound;
76+
}
77+
78+
export function isConflictError(error: unknown): error is FileSystemError {
79+
return error instanceof FileSystemError && error.conflict;
80+
}
81+
82+
export function isRateLimitError(error: unknown): error is FileSystemError {
83+
return error instanceof FileSystemError && error.rateLimit;
84+
}
85+
86+
export function isAuthError(error: unknown): error is FileSystemError | WarpTokenError {
87+
return error instanceof FileSystemError ? error.auth : isWarpTokenError(error);
88+
}

β€Žpackages/filesystem/googledrive/googledrive.test.tsβ€Ž

Lines changed: 294 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,31 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { LocalStorageDAO } from "@App/app/repo/localStorage";
3+
import { FileSystemError, isAuthError, isConflictError, isNotFoundError, isRateLimitError } from "../error";
24
import GoogleDriveFileSystem from "./googledrive";
35

6+
function createMockResponse(options: { ok?: boolean; status?: number; text?: string; json?: any }): Response {
7+
const { ok = true, status = 200, text = "", json = {} } = options;
8+
return {
9+
ok,
10+
status,
11+
text: vi.fn().mockResolvedValue(text),
12+
json: vi.fn().mockResolvedValue(json),
13+
headers: new Headers(),
14+
} as unknown as Response;
15+
}
16+
417
describe("GoogleDriveFileSystem", () => {
5-
beforeEach(() => {
18+
const localStorageDAO = new LocalStorageDAO();
19+
let originalFetch: typeof fetch;
20+
21+
beforeEach(async () => {
622
vi.clearAllMocks();
23+
await chrome.storage.local.clear();
24+
originalFetch = globalThis.fetch;
25+
});
26+
27+
afterEach(() => {
28+
vi.stubGlobal("fetch", originalFetch);
729
});
830

931
it("delete should be idempotent when file id is missing", async () => {
@@ -59,4 +81,274 @@ describe("GoogleDriveFileSystem", () => {
5981
expect(findSpy).toHaveBeenCalledWith("file.txt", "base-id");
6082
expect(requestSpy).toHaveBeenCalledTimes(1);
6183
});
84+
85+
it("writer should clear stale path cache and retry once on provider 404", async () => {
86+
const fs = new GoogleDriveFileSystem("/", "token");
87+
const notFoundError = new FileSystemError({
88+
provider: "googledrive",
89+
message: "Parent not found",
90+
status: 404,
91+
notFound: true,
92+
});
93+
const findFolderSpy = vi
94+
.spyOn(fs, "findFolderByName")
95+
.mockResolvedValueOnce({ id: "stale-base-id", name: "Base" })
96+
.mockResolvedValueOnce({ id: "fresh-base-id", name: "Base" });
97+
98+
await fs.ensureDirExists("/Base");
99+
100+
const writer = await fs.create("Base/file.txt");
101+
const findFileSpy = vi
102+
.spyOn(fs, "findFileInDirectory")
103+
.mockRejectedValueOnce(notFoundError)
104+
.mockResolvedValueOnce(null);
105+
const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({});
106+
107+
await expect(writer.write("content")).resolves.toBeUndefined();
108+
109+
expect(findFolderSpy.mock.calls).toEqual([
110+
["Base", "appDataFolder"],
111+
["Base", "appDataFolder"],
112+
]);
113+
expect(findFileSpy.mock.calls).toEqual([
114+
["file.txt", "stale-base-id"],
115+
["file.txt", "fresh-base-id"],
116+
]);
117+
expect(requestSpy).toHaveBeenCalledTimes(1);
118+
});
119+
120+
it("writer should not retry non-404 provider errors", async () => {
121+
const fs = new GoogleDriveFileSystem("/", "token");
122+
const conflictError = new FileSystemError({
123+
provider: "googledrive",
124+
message: "Conflict",
125+
status: 409,
126+
conflict: true,
127+
});
128+
const writer = await fs.create("Base/file.txt");
129+
const ensureSpy = vi.spyOn(fs, "ensureDirExists").mockResolvedValue("base-id");
130+
const findFileSpy = vi.spyOn(fs, "findFileInDirectory").mockRejectedValue(conflictError);
131+
132+
await expect(writer.write("content")).rejects.toBe(conflictError);
133+
134+
expect(ensureSpy).toHaveBeenCalledTimes(1);
135+
expect(findFileSpy).toHaveBeenCalledTimes(1);
136+
});
137+
138+
it("list should clear stale path cache and retry once on provider 404", async () => {
139+
const fs = new GoogleDriveFileSystem("/Base", "token");
140+
const notFoundError = new FileSystemError({
141+
provider: "googledrive",
142+
message: "Folder not found",
143+
status: 404,
144+
notFound: true,
145+
});
146+
const findFolderSpy = vi.spyOn(fs, "findFolderByName").mockResolvedValueOnce({ id: "stale-base-id", name: "Base" });
147+
148+
await fs.ensureDirExists("/Base");
149+
150+
const requestSpy = vi
151+
.spyOn(fs, "request")
152+
.mockRejectedValueOnce(notFoundError)
153+
.mockResolvedValueOnce({ files: [{ id: "fresh-base-id", name: "Base" }] })
154+
.mockResolvedValueOnce({ files: [] });
155+
156+
await expect(fs.list()).resolves.toEqual([]);
157+
158+
expect(findFolderSpy).toHaveBeenCalledTimes(1);
159+
expect(String(requestSpy.mock.calls[0][0])).toContain("stale-base-id");
160+
expect(String(requestSpy.mock.calls[1][0])).toContain("name%3D'Base'");
161+
expect(String(requestSpy.mock.calls[2][0])).toContain("fresh-base-id");
162+
});
163+
164+
it("request should return retry result after token refresh", async () => {
165+
await localStorageDAO.saveValue("netdisk:token:googledrive", {
166+
accessToken: "expired-token",
167+
refreshToken: "refresh-token",
168+
createtime: Date.now(),
169+
});
170+
171+
const fs = new GoogleDriveFileSystem("/", "expired-token");
172+
const fetchMock = vi
173+
.fn()
174+
.mockResolvedValueOnce(
175+
createMockResponse({
176+
ok: false,
177+
status: 401,
178+
text: JSON.stringify({
179+
error: {
180+
code: 401,
181+
message: "Invalid Credentials",
182+
status: "UNAUTHENTICATED",
183+
},
184+
}),
185+
})
186+
)
187+
.mockResolvedValueOnce({
188+
json: vi.fn().mockResolvedValue({
189+
code: 0,
190+
data: {
191+
token: {
192+
access_token: "fresh-token",
193+
refresh_token: "fresh-refresh-token",
194+
},
195+
},
196+
}),
197+
} as unknown as Response)
198+
.mockResolvedValueOnce(
199+
createMockResponse({
200+
json: {
201+
files: [{ id: "ok" }],
202+
},
203+
})
204+
);
205+
vi.stubGlobal("fetch", fetchMock);
206+
207+
const data = await fs.request("https://www.googleapis.com/drive/v3/files");
208+
209+
expect(data.files).toHaveLength(1);
210+
expect(fetchMock).toHaveBeenCalledTimes(3);
211+
});
212+
213+
it("request should throw auth error when retry still gets 401", async () => {
214+
await localStorageDAO.saveValue("netdisk:token:googledrive", {
215+
accessToken: "expired-token",
216+
refreshToken: "refresh-token",
217+
createtime: Date.now(),
218+
});
219+
220+
const fs = new GoogleDriveFileSystem("/", "expired-token");
221+
vi.stubGlobal(
222+
"fetch",
223+
vi
224+
.fn()
225+
.mockResolvedValueOnce(createMockResponse({ ok: false, status: 401, text: "expired" }))
226+
.mockResolvedValueOnce({
227+
json: vi.fn().mockResolvedValue({
228+
code: 0,
229+
data: {
230+
token: {
231+
access_token: "fresh-token",
232+
refresh_token: "fresh-refresh-token",
233+
},
234+
},
235+
}),
236+
} as unknown as Response)
237+
.mockResolvedValueOnce(createMockResponse({ ok: false, status: 401, text: "still expired" }))
238+
);
239+
240+
try {
241+
await fs.request("https://www.googleapis.com/drive/v3/files");
242+
throw new Error("Expected request to fail");
243+
} catch (error) {
244+
expect(error).toBeInstanceOf(FileSystemError);
245+
expect(isAuthError(error)).toBe(true);
246+
expect(error).toMatchObject({
247+
provider: "googledrive",
248+
status: 401,
249+
auth: true,
250+
});
251+
}
252+
});
253+
254+
it("request should throw typed not found error", async () => {
255+
const fs = new GoogleDriveFileSystem("/", "token");
256+
vi.stubGlobal(
257+
"fetch",
258+
vi.fn().mockResolvedValueOnce(
259+
createMockResponse({
260+
ok: false,
261+
status: 404,
262+
text: JSON.stringify({
263+
error: {
264+
code: 404,
265+
message: "File not found",
266+
status: "NOT_FOUND",
267+
},
268+
}),
269+
})
270+
)
271+
);
272+
273+
try {
274+
await fs.request("https://www.googleapis.com/drive/v3/files/missing");
275+
throw new Error("Expected request to fail");
276+
} catch (error) {
277+
expect(error).toBeInstanceOf(FileSystemError);
278+
expect(isNotFoundError(error)).toBe(true);
279+
expect(error).toMatchObject({
280+
provider: "googledrive",
281+
status: 404,
282+
code: "NOT_FOUND",
283+
notFound: true,
284+
});
285+
}
286+
});
287+
288+
it.each([409, 412])("request should throw typed conflict error for status %s", async (status) => {
289+
const fs = new GoogleDriveFileSystem("/", "token");
290+
vi.stubGlobal(
291+
"fetch",
292+
vi.fn().mockResolvedValueOnce(
293+
createMockResponse({
294+
ok: false,
295+
status,
296+
text: JSON.stringify({
297+
error: {
298+
code: status,
299+
message: "Conflict",
300+
status: status === 409 ? "ABORTED" : "FAILED_PRECONDITION",
301+
},
302+
}),
303+
})
304+
)
305+
);
306+
307+
try {
308+
await fs.request("https://www.googleapis.com/drive/v3/files/conflict");
309+
throw new Error("Expected request to fail");
310+
} catch (error) {
311+
expect(error).toBeInstanceOf(FileSystemError);
312+
expect(isConflictError(error)).toBe(true);
313+
expect(error).toMatchObject({
314+
provider: "googledrive",
315+
status,
316+
conflict: true,
317+
});
318+
}
319+
});
320+
321+
it("request should throw typed rate-limit error", async () => {
322+
const fs = new GoogleDriveFileSystem("/", "token");
323+
vi.stubGlobal(
324+
"fetch",
325+
vi.fn().mockResolvedValueOnce(
326+
createMockResponse({
327+
ok: false,
328+
status: 429,
329+
text: JSON.stringify({
330+
error: {
331+
code: 429,
332+
message: "Quota exceeded",
333+
status: "RESOURCE_EXHAUSTED",
334+
},
335+
}),
336+
})
337+
)
338+
);
339+
340+
try {
341+
await fs.request("https://www.googleapis.com/drive/v3/files");
342+
throw new Error("Expected request to fail");
343+
} catch (error) {
344+
expect(error).toBeInstanceOf(FileSystemError);
345+
expect(isRateLimitError(error)).toBe(true);
346+
expect(error).toMatchObject({
347+
provider: "googledrive",
348+
status: 429,
349+
retryable: true,
350+
rateLimit: true,
351+
});
352+
}
353+
});
62354
});

0 commit comments

Comments
Β (0)