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
65 changes: 65 additions & 0 deletions packages/filesystem/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,68 @@ export class WarpNetworkError {
export function isNetworkError(error: any): error is WarpNetworkError {
return error instanceof WarpNetworkError;
}

export type FileSystemProvider = "googledrive" | "onedrive" | "dropbox" | "baidu" | "webdav" | "s3" | "zip";

export type FileSystemErrorOptions = {
provider: FileSystemProvider;
message: string;
status?: number;
code?: string;
retryable?: boolean;
conflict?: boolean;
auth?: boolean;
notFound?: boolean;
rateLimit?: boolean;
raw?: unknown;
};

export class FileSystemError extends Error {
provider: FileSystemProvider;

status?: number;

code?: string;

retryable: boolean;

conflict: boolean;

auth: boolean;

notFound: boolean;

rateLimit: boolean;

raw?: unknown;

constructor(options: FileSystemErrorOptions) {
super(options.message);
this.name = "FileSystemError";
this.provider = options.provider;
this.status = options.status;
this.code = options.code;
this.retryable = options.retryable ?? false;
this.conflict = options.conflict ?? false;
this.auth = options.auth ?? false;
this.notFound = options.notFound ?? false;
this.rateLimit = options.rateLimit ?? false;
this.raw = options.raw;
}
}

export function isNotFoundError(error: unknown): error is FileSystemError {
return error instanceof FileSystemError && error.notFound;
}

export function isConflictError(error: unknown): error is FileSystemError {
return error instanceof FileSystemError && error.conflict;
}

export function isRateLimitError(error: unknown): error is FileSystemError {
return error instanceof FileSystemError && error.rateLimit;
}

export function isAuthError(error: unknown): error is FileSystemError | WarpTokenError {
return error instanceof FileSystemError ? error.auth : isWarpTokenError(error);
}
296 changes: 294 additions & 2 deletions packages/filesystem/googledrive/googledrive.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { LocalStorageDAO } from "@App/app/repo/localStorage";
import { FileSystemError, isAuthError, isConflictError, isNotFoundError, isRateLimitError } from "../error";
import GoogleDriveFileSystem from "./googledrive";

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("GoogleDriveFileSystem", () => {
beforeEach(() => {
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);
});

it("delete should be idempotent when file id is missing", async () => {
Expand Down Expand Up @@ -59,4 +81,274 @@ describe("GoogleDriveFileSystem", () => {
expect(findSpy).toHaveBeenCalledWith("file.txt", "base-id");
expect(requestSpy).toHaveBeenCalledTimes(1);
});

it("writer should clear stale path cache and retry once on provider 404", async () => {
const fs = new GoogleDriveFileSystem("/", "token");
const notFoundError = new FileSystemError({
provider: "googledrive",
message: "Parent not found",
status: 404,
notFound: true,
});
const findFolderSpy = vi
.spyOn(fs, "findFolderByName")
.mockResolvedValueOnce({ id: "stale-base-id", name: "Base" })
.mockResolvedValueOnce({ id: "fresh-base-id", name: "Base" });

await fs.ensureDirExists("/Base");

const writer = await fs.create("Base/file.txt");
const findFileSpy = vi
.spyOn(fs, "findFileInDirectory")
.mockRejectedValueOnce(notFoundError)
.mockResolvedValueOnce(null);
const requestSpy = vi.spyOn(fs, "request").mockResolvedValue({});

await expect(writer.write("content")).resolves.toBeUndefined();

expect(findFolderSpy.mock.calls).toEqual([
["Base", "appDataFolder"],
["Base", "appDataFolder"],
]);
expect(findFileSpy.mock.calls).toEqual([
["file.txt", "stale-base-id"],
["file.txt", "fresh-base-id"],
]);
expect(requestSpy).toHaveBeenCalledTimes(1);
});

it("writer should not retry non-404 provider errors", async () => {
const fs = new GoogleDriveFileSystem("/", "token");
const conflictError = new FileSystemError({
provider: "googledrive",
message: "Conflict",
status: 409,
conflict: true,
});
const writer = await fs.create("Base/file.txt");
const ensureSpy = vi.spyOn(fs, "ensureDirExists").mockResolvedValue("base-id");
const findFileSpy = vi.spyOn(fs, "findFileInDirectory").mockRejectedValue(conflictError);

await expect(writer.write("content")).rejects.toBe(conflictError);

expect(ensureSpy).toHaveBeenCalledTimes(1);
expect(findFileSpy).toHaveBeenCalledTimes(1);
});

it("list should clear stale path cache and retry once on provider 404", async () => {
const fs = new GoogleDriveFileSystem("/Base", "token");
const notFoundError = new FileSystemError({
provider: "googledrive",
message: "Folder not found",
status: 404,
notFound: true,
});
const findFolderSpy = vi.spyOn(fs, "findFolderByName").mockResolvedValueOnce({ id: "stale-base-id", name: "Base" });

await fs.ensureDirExists("/Base");

const requestSpy = vi
.spyOn(fs, "request")
.mockRejectedValueOnce(notFoundError)
.mockResolvedValueOnce({ files: [{ id: "fresh-base-id", name: "Base" }] })
.mockResolvedValueOnce({ files: [] });

await expect(fs.list()).resolves.toEqual([]);

expect(findFolderSpy).toHaveBeenCalledTimes(1);
expect(String(requestSpy.mock.calls[0][0])).toContain("stale-base-id");
expect(String(requestSpy.mock.calls[1][0])).toContain("name%3D'Base'");
expect(String(requestSpy.mock.calls[2][0])).toContain("fresh-base-id");
});

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

const fs = new GoogleDriveFileSystem("/", "expired-token");
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 401,
text: JSON.stringify({
error: {
code: 401,
message: "Invalid Credentials",
status: "UNAUTHENTICATED",
},
}),
})
)
.mockResolvedValueOnce({
json: vi.fn().mockResolvedValue({
code: 0,
data: {
token: {
access_token: "fresh-token",
refresh_token: "fresh-refresh-token",
},
},
}),
} as unknown as Response)
.mockResolvedValueOnce(
createMockResponse({
json: {
files: [{ id: "ok" }],
},
})
);
vi.stubGlobal("fetch", fetchMock);

const data = await fs.request("https://www.googleapis.com/drive/v3/files");

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

it("request should throw auth error when retry still gets 401", async () => {
await localStorageDAO.saveValue("netdisk:token:googledrive", {
accessToken: "expired-token",
refreshToken: "refresh-token",
createtime: Date.now(),
});

const fs = new GoogleDriveFileSystem("/", "expired-token");
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValueOnce(createMockResponse({ ok: false, status: 401, text: "expired" }))
.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: false, status: 401, text: "still expired" }))
);

try {
await fs.request("https://www.googleapis.com/drive/v3/files");
throw new Error("Expected request to fail");
} catch (error) {
expect(error).toBeInstanceOf(FileSystemError);
expect(isAuthError(error)).toBe(true);
expect(error).toMatchObject({
provider: "googledrive",
status: 401,
auth: true,
});
}
});

it("request should throw typed not found error", async () => {
const fs = new GoogleDriveFileSystem("/", "token");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 404,
text: JSON.stringify({
error: {
code: 404,
message: "File not found",
status: "NOT_FOUND",
},
}),
})
)
);

try {
await fs.request("https://www.googleapis.com/drive/v3/files/missing");
throw new Error("Expected request to fail");
} catch (error) {
expect(error).toBeInstanceOf(FileSystemError);
expect(isNotFoundError(error)).toBe(true);
expect(error).toMatchObject({
provider: "googledrive",
status: 404,
code: "NOT_FOUND",
notFound: true,
});
}
});

it.each([409, 412])("request should throw typed conflict error for status %s", async (status) => {
const fs = new GoogleDriveFileSystem("/", "token");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
createMockResponse({
ok: false,
status,
text: JSON.stringify({
error: {
code: status,
message: "Conflict",
status: status === 409 ? "ABORTED" : "FAILED_PRECONDITION",
},
}),
})
)
);

try {
await fs.request("https://www.googleapis.com/drive/v3/files/conflict");
throw new Error("Expected request to fail");
} catch (error) {
expect(error).toBeInstanceOf(FileSystemError);
expect(isConflictError(error)).toBe(true);
expect(error).toMatchObject({
provider: "googledrive",
status,
conflict: true,
});
}
});

it("request should throw typed rate-limit error", async () => {
const fs = new GoogleDriveFileSystem("/", "token");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 429,
text: JSON.stringify({
error: {
code: 429,
message: "Quota exceeded",
status: "RESOURCE_EXHAUSTED",
},
}),
})
)
);

try {
await fs.request("https://www.googleapis.com/drive/v3/files");
throw new Error("Expected request to fail");
} catch (error) {
expect(error).toBeInstanceOf(FileSystemError);
expect(isRateLimitError(error)).toBe(true);
expect(error).toMatchObject({
provider: "googledrive",
status: 429,
retryable: true,
rateLimit: true,
});
}
});
});
Loading
Loading