Skip to content
Open
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
98 changes: 98 additions & 0 deletions packages/core/src/sync/webdav-backend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it, vi } from "vitest";

import { type FetchOptions, type IPlatformService, setPlatformService } from "../services/platform";
import { DEFAULT_WEBDAV_REMOTE_ROOT, type WebDavConfig } from "./sync-backend";
import { WebDavBackend } from "./webdav-backend";

function installFetchStub(
handler: (url: string, options?: FetchOptions) => Response | Promise<Response>,
): void {
setPlatformService({
platformType: "web",
isMobile: false,
isDesktop: false,
fetch: handler,
} as unknown as IPlatformService);
}

function webDavConfig(): WebDavConfig {
return {
type: "webdav",
url: "https://dav.example.com/dav",
username: "alice",
remoteRoot: DEFAULT_WEBDAV_REMOTE_ROOT,
allowInsecure: false,
autoSync: false,
syncIntervalMins: 30,
wifiOnly: false,
notifyOnComplete: false,
};
}

describe("WebDavBackend directory healing", () => {
afterEach(() => {
setPlatformService(null as unknown as IPlatformService);
});

it("putJSON creates the missing /readany/sync collection and retries instead of surfacing the 404", async () => {
const calls: { method: string; url: string }[] = [];
installFetchStub((url, options) => {
const method = String(options?.method ?? "GET");
calls.push({ method, url });
if (method === "PUT") {
const putCount = calls.filter((call) => call.method === "PUT").length;
return new Response("", { status: putCount === 1 ? 404 : 200 });
}
if (method === "PROPFIND") {
return new Response("", { status: url.endsWith("/readany/") ? 207 : 404 });
}
if (method === "MKCOL") {
return new Response("", { status: 201 });
}
return new Response("", { status: 404 });
});

const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const backend = new WebDavBackend(webDavConfig(), "secret");
await backend.putJSON("/readany/sync/device-abc.json", { deviceId: "abc" });
} finally {
warnSpy.mockRestore();
logSpy.mockRestore();
}

expect(calls.filter((call) => call.method === "PUT")).toHaveLength(2);
expect(
calls.some((call) => call.method === "MKCOL" && call.url.endsWith("/readany/sync/")),
).toBe(true);
});

it("ensureDirectories creates the legacy file/cover collections through ensureDirectory", async () => {
const calls: { method: string; url: string }[] = [];
installFetchStub((url, options) => {
const method = String(options?.method ?? "GET");
calls.push({ method, url });
if (method === "PROPFIND") {
return new Response("", { status: 404 });
}
if (method === "MKCOL") {
return new Response("", { status: 201 });
}
return new Response("", { status: 404 });
});

const backend = new WebDavBackend(webDavConfig(), "secret");
await backend.ensureDirectories();

const mkcolUrls = calls.filter((call) => call.method === "MKCOL").map((call) => call.url);
expect(mkcolUrls).toEqual([
"https://dav.example.com/dav/readany/",
"https://dav.example.com/dav/readany/sync/",
"https://dav.example.com/dav/readany/data/",
"https://dav.example.com/dav/readany/data/books/",
"https://dav.example.com/dav/readany/data/file/",
"https://dav.example.com/dav/readany/data/cover/",
]);
});
});
52 changes: 30 additions & 22 deletions packages/core/src/sync/webdav-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,46 +77,50 @@ export class WebDavBackend implements ISyncBackend {
await this.client.ensureDirectory(this.resolvePath(REMOTE_DATA));
// New per-book layout root
await this.client.ensureDirectory(this.resolvePath(REMOTE_BOOKS_ROOT));
// Legacy directories kept ensured during the transition window (cheap & safe)
await this.client.mkcol(this.resolvePath(REMOTE_FILES));
await this.client.mkcol(this.resolvePath(REMOTE_COVERS));
// Legacy directories kept ensured during the transition window (cheap & safe).
// ensureDirectory (not bare mkcol): some servers answer 409 to MKCOL when
// an intermediate collection is missing, which must not be read as success.
await this.client.ensureDirectory(this.resolvePath(REMOTE_FILES));
await this.client.ensureDirectory(this.resolvePath(REMOTE_COVERS));
this.directoriesEnsured = true;
}

async put(path: string, data: Uint8Array): Promise<void> {
/**
* Some WebDAV servers (Synology, QNAP, 飞牛, etc.) return 403/404/409 when
* PUT-ing into a directory that doesn't exist yet. Ensure the parent and
* retry once — most uploads land on an existing dir, so this catch path
* only fires for first-time uploads into a brand-new folder. Shared by
* every upload entry point (put/putFile/putJSON) so no caller can bypass it.
*/
private async putWithDirectoryHeal(
path: string,
write: (resolvedPath: string) => Promise<void>,
): Promise<void> {
const resolved = this.resolvePath(path);
try {
await this.client.put(resolved, data);
await write(resolved);
} catch (e) {
// Some WebDAV servers (Synology, QNAP, 飞牛, etc.) return 403/404/409 when
// PUT-ing into a directory that doesn't exist yet. Ensure the parent and
// retry once — most uploads land on an existing dir, so this catch path
// only fires for first-time uploads into a brand-new per-book folder.
const message = e instanceof Error ? e.message : String(e);
if (!/\b(403|404|409)\b/.test(message)) throw e;
const parent = resolved.substring(0, resolved.lastIndexOf("/"));
if (!parent || parent === "/") throw e;
await this.client.ensureDirectory(parent);
await this.client.put(resolved, data);
await write(resolved);
}
}

async put(path: string, data: Uint8Array): Promise<void> {
await this.putWithDirectoryHeal(path, (resolved) => this.client.put(resolved, data));
}

async putFile(
path: string,
localFilePath: string,
onProgress?: (loaded: number, total: number) => void,
): Promise<void> {
const resolved = this.resolvePath(path);
try {
await this.client.putFile(resolved, localFilePath, onProgress);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
if (!/\b(403|404|409)\b/.test(message)) throw e;
const parent = resolved.substring(0, resolved.lastIndexOf("/"));
if (!parent || parent === "/") throw e;
await this.client.ensureDirectory(parent);
await this.client.putFile(resolved, localFilePath, onProgress);
}
await this.putWithDirectoryHeal(path, (resolved) =>
this.client.putFile(resolved, localFilePath, onProgress),
);
}

async get(path: string): Promise<Uint8Array> {
Expand All @@ -143,7 +147,11 @@ export class WebDavBackend implements ISyncBackend {
}

async putJSON<T>(path: string, data: T): Promise<void> {
await this.client.putJSON(this.resolvePath(path), data);
// Must go through the shared heal path: this is how device snapshots and
// the file manifest get written, and a missing /readany/sync used to
// surface here as "WebDAV PUT failed … 404" because client.putJSON
// bypassed the retry that put()/putFile() had.
await this.putWithDirectoryHeal(path, (resolved) => this.client.putJSON(resolved, data));
}

async listDir(path: string): Promise<RemoteFile[]> {
Expand Down
115 changes: 115 additions & 0 deletions packages/core/src/sync/webdav-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,121 @@ describe("WebDavClient PROPFIND parsing", () => {
});
});

describe("WebDavClient PUT/mkcol self-healing", () => {
afterEach(() => {
setPlatformService(null as unknown as IPlatformService);
});

it("heals a PUT 404 by force-creating the parent collection chain and retrying once", async () => {
const calls: { method: string; url: string }[] = [];
installFetchStub((url, options) => {
const method = String(options?.method ?? "GET");
calls.push({ method, url });
if (method === "PUT") {
const putCount = calls.filter((call) => call.method === "PUT").length;
return new Response("", { status: putCount === 1 ? 404 : 200 });
}
if (method === "MKCOL") {
return new Response("", { status: 201 });
}
return new Response("", { status: 404 });
});

const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
try {
const client = new WebDavClient("https://dav.example.com/dav", "alice", "secret");
await client.putJSON("/readany/sync/device-abc.json", { ok: true });
} finally {
warnSpy.mockRestore();
logSpy.mockRestore();
}

expect(calls.map((call) => call.method)).toEqual(["PUT", "MKCOL", "MKCOL", "PUT"]);
expect(calls[1]?.url).toBe("https://dav.example.com/dav/readany/");
expect(calls[2]?.url).toBe("https://dav.example.com/dav/readany/sync/");
expect(calls[0]?.url).toBe("https://dav.example.com/dav/readany/sync/device-abc.json");
expect(calls[3]?.url).toBe(calls[0]?.url);
});

it("heals an MKCOL 409 by force-creating the whole collection chain", async () => {
const calls: { method: string; url: string }[] = [];
installFetchStub((url, options) => {
const method = String(options?.method ?? "GET");
calls.push({ method, url });
if (method === "PROPFIND") {
return new Response("", { status: 207 });
}
if (method === "MKCOL") {
const isBooksCall = url.endsWith("/readany/data/books/");
const booksAttempts = calls.filter(
(call) => call.method === "MKCOL" && call.url.endsWith("/readany/data/books/"),
).length;
return new Response("", { status: isBooksCall && booksAttempts === 1 ? 409 : 201 });
}
return new Response("", { status: 404 });
});

const client = new WebDavClient("https://dav.example.com/dav", "alice", "secret");
await client.mkcol("/readany/data/books");

expect(calls.map((call) => call.method)).toEqual(["MKCOL", "MKCOL", "MKCOL", "MKCOL"]);
expect(calls.map((call) => call.url)).toEqual([
"https://dav.example.com/dav/readany/data/books/",
"https://dav.example.com/dav/readany/",
"https://dav.example.com/dav/readany/data/",
"https://dav.example.com/dav/readany/data/books/",
]);
});

it("retries MKCOL without the trailing slash on servers that 409 collection URIs", async () => {
const calls: { method: string; url: string }[] = [];
installFetchStub((url, options) => {
const method = String(options?.method ?? "GET");
calls.push({ method, url });
if (method === "MKCOL") {
return new Response("", { status: url.endsWith("/") ? 409 : 201 });
}
return new Response("", { status: 404 });
});

const client = new WebDavClient("https://dav.example.com/dav", "alice", "secret");
await client.mkcol("/readany/sync");

expect(calls.map((call) => call.method)).toEqual(["MKCOL", "MKCOL", "MKCOL", "MKCOL", "MKCOL"]);
expect(calls.map((call) => call.url)).toEqual([
"https://dav.example.com/dav/readany/sync/",
"https://dav.example.com/dav/readany/",
"https://dav.example.com/dav/readany",
"https://dav.example.com/dav/readany/sync/",
"https://dav.example.com/dav/readany/sync",
]);
});

it("getJSON returns null for 404 by status and rethrows other failures", async () => {
installFetchStub(() => new Response("", { status: 404 }));
const client = new WebDavClient("https://dav.example.com/dav", "alice", "secret");
await expect(client.getJSON("/readany/sync/missing.json")).resolves.toBeNull();

installFetchStub(() => new Response("", { status: 401 }));
await expect(client.getJSON("/readany/sync/missing.json")).rejects.toThrow();
});

it("skips re-probing collections confirmed earlier in the same client", async () => {
const calls: { method: string; url: string }[] = [];
installFetchStub((url, options) => {
calls.push({ method: String(options?.method ?? "GET"), url });
return new Response("", { status: 207 });
});

const client = new WebDavClient("https://dav.example.com/dav", "alice", "secret");
await client.ensureDirectory("/readany");
await client.ensureDirectory("/readany");

expect(calls).toHaveLength(1);
});
});

describe("sanitizeWebDavRemoteRoot", () => {
it("preserves case because WebDAV paths can be case-sensitive", () => {
expect(sanitizeWebDavRemoteRoot("ReadAny/DeviceSync")).toBe("ReadAny/DeviceSync");
Expand Down
Loading